Move all files to 2017/

This commit is contained in:
Oliver Davies 2025-09-29 22:25:17 +01:00
parent ac7370f67f
commit 2875863330
15717 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,817 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: moderated_content
label: 'Moderated content'
module: views
description: 'Find and moderate content.'
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view any unpublished content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Filter
reset_button: true
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: full
options:
items_per_page: 50
offset: 0
id: 0
total_pages: null
tags:
previous: ' Previous'
next: 'Next '
first: '« First'
last: 'Last »'
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
quantity: 9
style:
type: table
options:
grouping: { }
row_class: ''
default_row_class: true
override: true
sticky: true
caption: ''
summary: ''
description: ''
columns:
title: title
type: type
name: name
moderation_state: moderation_state
changed: changed
info:
title:
sortable: true
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
type:
sortable: true
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
name:
sortable: false
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
moderation_state:
sortable: true
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
changed:
sortable: true
default_sort_order: desc
align: ''
separator: ''
empty_column: false
responsive: ''
default: changed
empty_table: true
row:
type: fields
fields:
title:
id: title
table: node_field_revision
field: title
relationship: none
group_type: group
admin_label: ''
label: Title
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: false
ellipsis: false
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: title
plugin_id: field
type:
id: type
table: node_field_data
field: type
relationship: nid
group_type: group
admin_label: ''
label: 'Content type'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: entity_reference_label
settings:
link: false
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: type
plugin_id: field
name:
id: name
table: users_field_data
field: name
relationship: uid
group_type: group
admin_label: ''
label: Author
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: user_name
settings:
link_to_entity: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: user
entity_field: name
plugin_id: field
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: ''
label: 'Moderation state'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: content_moderation_state
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
plugin_id: field
changed:
id: changed
table: node_field_revision
field: changed
relationship: none
group_type: group
admin_label: ''
label: Updated
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: false
ellipsis: false
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: timestamp
settings:
date_format: short
custom_date_format: ''
timezone: ''
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: changed
plugin_id: field
operations:
id: operations
table: node_revision
field: operations
relationship: none
group_type: group
admin_label: ''
label: Operations
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
destination: true
entity_type: node
plugin_id: entity_operations
filters:
latest_revision:
id: latest_revision
table: node_revision
field: latest_revision
relationship: none
group_type: group
admin_label: ''
operator: '='
value: ''
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: latest_revision
title:
id: title
table: node_field_revision
field: title
relationship: none
group_type: group
admin_label: ''
operator: contains
value: ''
group: 1
exposed: true
expose:
operator_id: title_op
label: Title
description: ''
use_operator: false
operator: title_op
identifier: title
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
entity_field: title
plugin_id: string
type:
id: type
table: node_field_data
field: type
relationship: nid
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: type_op
label: 'Content type'
description: ''
use_operator: false
operator: type_op
identifier: type
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
entity_field: type
plugin_id: bundle
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: in
value:
editorial-draft: editorial-draft
editorial-archived: editorial-archived
group: 1
exposed: true
expose:
operator_id: moderation_state_op
label: 'Moderation state'
description: ''
use_operator: false
operator: moderation_state_op
identifier: moderation_state
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: true
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
langcode:
id: langcode
table: node_field_revision
field: langcode
relationship: none
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: langcode_op
label: Language
description: ''
use_operator: false
operator: langcode_op
identifier: langcode
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
entity_field: langcode
plugin_id: language
moderation_state_1:
id: moderation_state_1
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: 'not in'
value:
editorial-published: editorial-published
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
sorts: { }
title: 'Moderated content'
header: { }
footer: { }
empty:
area_text_custom:
id: area_text_custom
table: views
field: area_text_custom
relationship: none
group_type: group
admin_label: ''
empty: true
tokenize: false
content: 'No moderated content available. Only pending versions of content, such as drafts, are listed here.'
plugin_id: text_custom
relationships:
nid:
id: nid
table: node_field_revision
field: nid
relationship: none
group_type: group
admin_label: 'Get the actual content from a content revision.'
required: false
entity_type: node
entity_field: nid
plugin_id: standard
uid:
id: uid
table: node_field_revision
field: uid
relationship: none
group_type: group
admin_label: User
required: false
entity_type: node
entity_field: uid
plugin_id: standard
arguments: { }
display_extenders: { }
filter_groups:
operator: AND
groups:
1: AND
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
moderated_content:
display_plugin: page
id: moderated_content
display_title: 'Moderated content'
position: 1
display_options:
display_extenders: { }
path: admin/content/moderated
display_description: ''
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,35 @@
content_moderation.state:
type: workflows.state
mapping:
published:
type: boolean
label: 'Is published'
default_revision:
type: boolean
label: 'Is default revision'
workflow.type_settings.content_moderation:
type: mapping
mapping:
states:
type: sequence
label: 'Content moderation states'
orderby: key
sequence:
type: content_moderation.state
label: 'States'
transitions:
type: sequence
orderby: key
sequence:
type: workflows.transition
label: 'Transitions'
entity_types:
type: sequence
label: 'Entity types'
sequence:
type: sequence
label: 'Bundles'
sequence:
type: string
label: 'Bundle ID'

View file

@ -0,0 +1,18 @@
views.filter.latest_revision:
type: views_filter
label: 'Latest revision'
mapping:
value:
type: string
label: 'Value'
views.filter.moderation_state_filter:
type: views.filter.in_operator
label: 'Moderation state filter'
mapping:
limit_workflows:
type: sequence
label: 'Workflow'
sequence:
type: string
label: 'Workflow'

View file

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

View file

@ -0,0 +1,9 @@
name: 'Content Moderation'
type: module
description: 'Provides moderation states for content.'
version: VERSION
core: 8.x
package: Core
configure: entity.workflow.collection
dependencies:
- drupal:workflows

View file

@ -0,0 +1,31 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Content Moderation module.
*/
/**
* Implements hook_requirements().
*/
function content_moderation_requirements($phase) {
$requirements = [];
if ($phase === 'install' && \Drupal::moduleHandler()->moduleExists('workspaces')) {
$requirements['workspaces_incompatibility'] = [
'severity' => REQUIREMENT_ERROR,
'description' => t('Content Moderation can not be installed when Workspaces is also installed.'),
];
}
return $requirements;
}
/**
* Remove the 'content_revision_tracker' table.
*/
function content_moderation_update_8401() {
$database_schema = \Drupal::database()->schema();
if ($database_schema->tableExists('content_revision_tracker')) {
$database_schema->dropTable('content_revision_tracker');
}
}

View file

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

View file

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

View file

@ -0,0 +1,321 @@
<?php
/**
* @file
* Contains content_moderation.module.
*/
use Drupal\content_moderation\EntityOperations;
use Drupal\content_moderation\EntityTypeInfo;
use Drupal\content_moderation\ContentPreprocess;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublish;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Action\Plugin\Action\PublishAction;
use Drupal\Core\Action\Plugin\Action\UnpublishAction;
use Drupal\workflows\Entity\Workflow;
use Drupal\views\Entity\View;
/**
* Implements hook_help().
*/
function content_moderation_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
// Main module help for the content_moderation module.
case 'help.page.content_moderation':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Content Moderation module allows you to expand on Drupal\'s "unpublished" and "published" states for content. It allows you to have a published version that is live, but have a separate working copy that is undergoing review before it is published. This is achieved by using <a href=":workflows">Workflows</a> to apply different states and transitions to entities as needed. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation', ':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString()]) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Applying workflows') . '</dt>';
$output .= '<dd>' . t('Content Moderation allows you to apply <a href=":workflows">Workflows</a> to content, custom blocks, and other <a href=":field_help" title="Field module help, with background on content entities">content entities</a>, to provide more fine-grained publishing options. For example, a Basic page might have states such as Draft and Published, with allowed transitions such as Draft to Published (making the current revision "live"), and Published to Draft (making a new draft revision of published content).', [':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString(), ':field_help' => Url::fromRoute('help.page', ['name' => 'field'])->toString()]) . '</dd>';
if (\Drupal::moduleHandler()->moduleExists('views')) {
$moderated_content_view = View::load('moderated_content');
if (isset($moderated_content_view) && $moderated_content_view->status() === TRUE) {
$output .= '<dt>' . t('Moderating content') . '</dt>';
$output .= '<dd>' . t('You can view a list of content awaiting moderation on the <a href=":moderated">moderated content page</a>. This will show any content in an unpublished state, such as Draft or Archived, to help surface content that requires more work from content editors.', [':moderated' => Url::fromRoute('view.moderated_content.moderated_content')->toString()]) . '</dd>';
}
}
$output .= '<dt>' . t('Configure Content Moderation permissions') . '</dt>';
$output .= '<dd>' . t('Each transition is exposed as a permission. If a user has the permission for a transition, they can use the transition to change the state of the content item, from Draft to Published.') . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_entity_base_field_info().
*/
function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityBaseFieldInfo($entity_type);
}
/**
* Implements hook_entity_type_alter().
*/
function content_moderation_entity_type_alter(array &$entity_types) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityTypeAlter($entity_types);
}
/**
* Implements hook_entity_presave().
*/
function content_moderation_entity_presave(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityPresave($entity);
}
/**
* Implements hook_entity_insert().
*/
function content_moderation_entity_insert(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityInsert($entity);
}
/**
* Implements hook_entity_update().
*/
function content_moderation_entity_update(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityUpdate($entity);
}
/**
* Implements hook_entity_delete().
*/
function content_moderation_entity_delete(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityDelete($entity);
}
/**
* Implements hook_entity_revision_delete().
*/
function content_moderation_entity_revision_delete(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityRevisionDelete($entity);
}
/**
* Implements hook_entity_translation_delete().
*/
function content_moderation_entity_translation_delete(EntityInterface $translation) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityTranslationDelete($translation);
}
/**
* Implements hook_entity_prepare_form().
*/
function content_moderation_entity_prepare_form(EntityInterface $entity, $operation, FormStateInterface $form_state) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityPrepareForm($entity, $operation, $form_state);
}
/**
* Implements hook_form_alter().
*/
function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->formAlter($form, $form_state, $form_id);
}
/**
* Implements hook_preprocess_HOOK().
*/
function content_moderation_preprocess_node(&$variables) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(ContentPreprocess::class)
->preprocessNode($variables);
}
/**
* Implements hook_entity_extra_field_info().
*/
function content_moderation_entity_extra_field_info() {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityTypeInfo::class)
->entityExtraFieldInfo();
}
/**
* Implements hook_entity_view().
*/
function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityView($build, $entity, $display, $view_mode);
}
/**
* Implements hook_entity_access().
*
* Entities should be viewable if unpublished and the user has the appropriate
* permission. This permission is therefore effectively mandatory for any user
* that wants to moderate things.
*/
function content_moderation_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = Drupal::service('content_moderation.moderation_information');
$access_result = NULL;
if ($operation === 'view') {
$access_result = (($entity instanceof EntityPublishedInterface) && !$entity->isPublished())
? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
: AccessResult::neutral();
$access_result->addCacheableDependency($entity);
}
elseif ($operation === 'update' && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state) {
/** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
$transition_validation = \Drupal::service('content_moderation.state_transition_validation');
$valid_transition_targets = $transition_validation->getValidTransitions($entity, $account);
$access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden('No valid transitions exist for given account.');
$access_result->addCacheableDependency($entity);
$access_result->addCacheableDependency($account);
$workflow = $moderation_info->getWorkflowForEntity($entity);
$access_result->addCacheableDependency($workflow);
foreach ($valid_transition_targets as $valid_transition_target) {
$access_result->addCacheableDependency($valid_transition_target);
}
}
return $access_result;
}
/**
* Implements hook_entity_field_access().
*/
function content_moderation_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
if ($items && $operation === 'edit') {
/** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
$moderation_info = Drupal::service('content_moderation.moderation_information');
$entity_type = \Drupal::entityTypeManager()->getDefinition($field_definition->getTargetEntityTypeId());
$entity = $items->getEntity();
// Deny edit access to the published field if the entity is being moderated.
if ($entity_type->hasKey('published') && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state && $field_definition->getName() == $entity_type->getKey('published')) {
return AccessResult::forbidden('Cannot edit the published field of moderated entities.');
}
}
return AccessResult::neutral();
}
/**
* Implements hook_theme().
*/
function content_moderation_theme() {
return ['entity_moderation_form' => ['render element' => 'form']];
}
/**
* Implements hook_action_info_alter().
*/
function content_moderation_action_info_alter(&$definitions) {
// The publish/unpublish actions are not valid on moderated entities. So swap
// their implementations out for alternates that will become a no-op on a
// moderated entity. If another module has already swapped out those classes,
// though, we'll be polite and do nothing.
foreach ($definitions as &$definition) {
if ($definition['id'] === 'entity:publish_action' && $definition['class'] == PublishAction::class) {
$definition['class'] = ModerationOptOutPublish::class;
}
if ($definition['id'] === 'entity:unpublish_action' && $definition['class'] == UnpublishAction::class) {
$definition['class'] = ModerationOptOutUnpublish::class;
}
}
}
/**
* Implements hook_entity_bundle_info_alter().
*/
function content_moderation_entity_bundle_info_alter(&$bundles) {
$translatable = FALSE;
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
$plugin = $workflow->getTypePlugin();
foreach ($plugin->getEntityTypes() as $entity_type_id) {
foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) {
if (isset($bundles[$entity_type_id][$bundle_id])) {
$bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id();
// If we have even one moderation-enabled translatable bundle, we need
// to make the moderation state bundle translatable as well, to enable
// the revision translation merge logic also for content moderation
// state revisions.
if (!empty($bundles[$entity_type_id][$bundle_id]['translatable'])) {
$translatable = TRUE;
}
}
}
}
}
$bundles['content_moderation_state']['content_moderation_state']['translatable'] = $translatable;
}
/**
* Implements hook_entity_bundle_delete().
*/
function content_moderation_entity_bundle_delete($entity_type_id, $bundle_id) {
// Remove non-configuration based bundles from content moderation based
// workflows when they are removed.
foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
if ($workflow->getTypePlugin()->appliesToEntityTypeAndBundle($entity_type_id, $bundle_id)) {
$workflow->getTypePlugin()->removeEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
}
}
}
/**
* Implements hook_ENTITY_TYPE_insert().
*/
function content_moderation_workflow_insert(WorkflowInterface $entity) {
// Clear bundle cache so workflow gets added or removed from the bundle
// information.
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Clear field cache so extra field is added or removed.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
}
/**
* Implements hook_ENTITY_TYPE_update().
*/
function content_moderation_workflow_update(WorkflowInterface $entity) {
// Clear bundle cache so workflow gets added or removed from the bundle
// information.
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
// Clear field cache so extra field is added or removed.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
}

View file

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

View file

@ -0,0 +1,96 @@
<?php
/**
* @file
* Post update functions for the Content Moderation module.
*/
use Drupal\Core\Site\Settings;
use Drupal\workflows\Entity\Workflow;
/**
* Synchronize moderation state default revisions with their host entities.
*/
function content_moderation_post_update_update_cms_default_revisions(&$sandbox) {
// For every moderated entity, identify the default revision ID, track the
// corresponding "content_moderation_state" revision and save it as the new
// default revision, if needed.
// Initialize sandbox info.
$entity_type_id = &$sandbox['entity_type_id'];
if (!isset($entity_type_id)) {
$sandbox['bundles'] = [];
$sandbox['entity_type_ids'] = [];
/** @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) {
$sandbox['entity_type_ids'][$entity_type_id] = $entity_type_id;
foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle) {
$sandbox['bundles'][$entity_type_id][$bundle] = $bundle;
}
}
}
$sandbox['offset'] = 0;
$sandbox['limit'] = Settings::get('entity_update_batch_size', 50);
$sandbox['total'] = count($sandbox['entity_type_ids']);
$entity_type_id = array_shift($sandbox['entity_type_ids']);
}
// If there are no moderated bundles or we processed all of them, we are done.
$entity_type_manager = \Drupal::entityTypeManager();
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $content_moderation_state_storage */
$content_moderation_state_storage = $entity_type_manager->getStorage('content_moderation_state');
if (!$entity_type_id) {
$content_moderation_state_storage->resetCache();
$sandbox['#finished'] = 1;
return;
}
// Retrieve a batch of moderated entities to be processed.
$storage = $entity_type_manager->getStorage($entity_type_id);
$entity_type = $entity_type_manager->getDefinition($entity_type_id);
$query = $storage->getQuery()
->accessCheck(FALSE)
->sort($entity_type->getKey('id'))
->range($sandbox['offset'], $sandbox['limit']);
$bundle_key = $entity_type->getKey('bundle');
if ($bundle_key && !empty($sandbox['bundles'][$entity_type_id])) {
$bundles = array_keys($sandbox['bundles'][$entity_type_id]);
$query->condition($bundle_key, $bundles, 'IN');
}
$entity_ids = $query->execute();
// Compute progress status and skip to the next entity type, if needed.
$sandbox['#finished'] = ($sandbox['total'] - count($sandbox['entity_type_ids']) - 1) / $sandbox['total'];
if (!$entity_ids) {
$sandbox['offset'] = 0;
$entity_type_id = array_shift($sandbox['entity_type_ids']) ?: FALSE;
return;
}
// Load the "content_moderation_state" revisions corresponding to the
// moderated entity default revisions.
$result = $content_moderation_state_storage->getQuery()
->allRevisions()
->condition('content_entity_type_id', $entity_type_id)
->condition('content_entity_revision_id', array_keys($entity_ids), 'IN')
->execute();
/** @var \Drupal\Core\Entity\ContentEntityInterface[] $revisions */
$revisions = $content_moderation_state_storage->loadMultipleRevisions(array_keys($result));
// Update "content_moderation_state" data.
foreach ($revisions as $revision) {
if (!$revision->isDefaultRevision()) {
$revision->setNewRevision(FALSE);
$revision->isDefaultRevision(TRUE);
$content_moderation_state_storage->save($revision);
}
}
// Clear static cache to avoid memory issues.
$storage->resetCache($entity_ids);
$sandbox['offset'] += $sandbox['limit'];
}

View file

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

View file

@ -0,0 +1,27 @@
services:
paramconverter.latest_revision:
class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter
parent: paramconverter.entity
tags:
- { name: paramconverter, priority: 5 }
content_moderation.state_transition_validation:
class: \Drupal\content_moderation\StateTransitionValidation
arguments: ['@content_moderation.moderation_information']
content_moderation.moderation_information:
class: Drupal\content_moderation\ModerationInformation
arguments: ['@entity_type.manager', '@entity_type.bundle.info']
access_check.latest_revision:
class: Drupal\content_moderation\Access\LatestRevisionCheck
arguments: ['@content_moderation.moderation_information']
tags:
- { name: access_check, applies_to: _content_moderation_latest_version }
content_moderation.config_import_subscriber:
class: Drupal\content_moderation\EventSubscriber\ConfigImportSubscriber
arguments: ['@config.manager', '@entity_type.manager']
tags:
- { name: event_subscriber }
content_moderation.route_subscriber:
class: Drupal\content_moderation\Routing\ContentModerationRouteSubscriber
arguments: ['@entity_type.manager']
tags:
- { name: event_subscriber }

View file

@ -0,0 +1,30 @@
<?php
/**
* @file
* Provide views data for content_moderation.module.
*
* @ingroup views_module_handlers
*/
use Drupal\content_moderation\ViewsData;
/**
* Implements hook_views_data().
*/
function content_moderation_views_data() {
return _content_moderation_views_data_object()->getViewsData();
}
/**
* Creates a ViewsData object to respond to views hooks.
*
* @return \Drupal\content_moderation\ViewsData
* The content moderation ViewsData object.
*/
function _content_moderation_views_data_object() {
return new ViewsData(
\Drupal::service('entity_type.manager'),
\Drupal::service('content_moderation.moderation_information')
);
}

View file

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

View file

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

View file

@ -0,0 +1,99 @@
<?php
namespace Drupal\content_moderation\Access;
use Drupal\Core\Access\AccessException;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\user\EntityOwnerInterface;
use Symfony\Component\Routing\Route;
/**
* Access check for the entity moderation tab.
*/
class LatestRevisionCheck implements AccessInterface {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* Constructs a new LatestRevisionCheck.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information service.
*/
public function __construct(ModerationInformationInterface $moderation_information) {
$this->moderationInfo = $moderation_information;
}
/**
* Checks that there is a pending revision available.
*
* This checker assumes the presence of an '_entity_access' requirement key
* in the same form as used by EntityAccessCheck.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
* @param \Drupal\Core\Session\AccountInterface $account
* The current user account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @see \Drupal\Core\Entity\EntityAccessCheck
*/
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account) {
// This tab should not show up unless there's a reason to show it.
$entity = $this->loadEntity($route, $route_match);
if ($this->moderationInfo->hasPendingRevision($entity)) {
// Check the global permissions first.
$access_result = AccessResult::allowedIfHasPermissions($account, ['view latest version', 'view any unpublished content']);
if (!$access_result->isAllowed()) {
// Check entity owner access.
$owner_access = AccessResult::allowedIfHasPermissions($account, ['view latest version', 'view own unpublished content']);
$owner_access = $owner_access->andIf((AccessResult::allowedIf($entity instanceof EntityOwnerInterface && ($entity->getOwnerId() == $account->id()))));
$access_result = $access_result->orIf($owner_access);
}
return $access_result->addCacheableDependency($entity);
}
return AccessResult::forbidden('No pending revision for moderated entity.')->addCacheableDependency($entity);
}
/**
* Returns the default revision of the entity this route is for.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* returns the Entity in question.
*
* @throws \Drupal\Core\Access\AccessException
* An AccessException is thrown if the entity couldn't be loaded.
*/
protected function loadEntity(Route $route, RouteMatchInterface $route_match) {
$entity_type = $route->getOption('_content_moderation_entity_type');
if ($entity = $route_match->getParameter($entity_type)) {
if ($entity instanceof EntityInterface) {
return $entity;
}
}
throw new AccessException(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName()));
}
}

View file

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

View file

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

View file

@ -0,0 +1,42 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema;
/**
* Defines the content moderation state schema handler.
*/
class ContentModerationStateStorageSchema extends SqlContentEntityStorageSchema {
/**
* {@inheritdoc}
*/
protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) {
$schema = parent::getEntitySchema($entity_type, $reset);
// Creates unique keys to guarantee the integrity of the entity and to make
// the lookup in ModerationStateFieldItemList::getModerationState() fast.
$unique_keys = [
'content_entity_type_id',
'content_entity_id',
'content_entity_revision_id',
'workflow',
'langcode',
];
if ($data_table = $this->storage->getDataTable()) {
$schema[$data_table]['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
}
if ($revision_data_table = $this->storage->getRevisionDataTable()) {
$schema[$revision_data_table]['unique keys'] += [
'content_moderation_state__lookup' => $unique_keys,
];
}
return $schema;
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\node\Entity\Node;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Determines whether a route is the "Latest version" tab of a node.
*
* @internal
*/
class ContentPreprocess implements ContainerInjectionInterface {
/**
* The route match service.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructor.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* Current route match service.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_route_match')
);
}
/**
* @param array $variables
* Theme variables to preprocess.
*
* @see hook_preprocess_HOOK()
*/
public function preprocessNode(array &$variables) {
// Set the 'page' template variable when the node is being displayed on the
// "Latest version" tab provided by content_moderation.
$variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']);
}
/**
* Checks whether a route is the "Latest version" tab of a node.
*
* @param \Drupal\node\Entity\Node $node
* A node.
*
* @return bool
* True if the current route is the latest version tab of the given node.
*/
public function isLatestVersionPage(Node $node) {
return $this->routeMatch->getRouteName() == 'entity.node.latest_version'
&& ($pageNode = $this->routeMatch->getParameter('node'))
&& $pageNode->id() == $node->id();
}
}

View file

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

View file

@ -0,0 +1,237 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\user\UserInterface;
/**
* Defines the Content moderation state entity.
*
* @ContentEntityType(
* id = "content_moderation_state",
* label = @Translation("Content moderation state"),
* label_singular = @Translation("content moderation state"),
* label_plural = @Translation("content moderation states"),
* label_count = @PluralTranslation(
* singular = "@count content moderation state",
* plural = "@count content moderation states"
* ),
* handlers = {
* "storage_schema" = "Drupal\content_moderation\ContentModerationStateStorageSchema",
* "views_data" = "\Drupal\views\EntityViewsData",
* "access" = "Drupal\content_moderation\ContentModerationStateAccessControlHandler",
* },
* base_table = "content_moderation_state",
* revision_table = "content_moderation_state_revision",
* data_table = "content_moderation_state_field_data",
* revision_data_table = "content_moderation_state_field_revision",
* translatable = TRUE,
* internal = TRUE,
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* "uid" = "uid",
* "langcode" = "langcode",
* }
* )
*
* @internal
* This entity is marked internal because it should not be used directly to
* alter the moderation state of an entity. Instead, the computed
* moderation_state field should be set on the entity directly.
*/
class ContentModerationState extends ContentEntityBase implements ContentModerationStateInterface {
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['uid'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('User'))
->setDescription(t('The username of the entity creator.'))
->setSetting('target_type', 'user')
->setDefaultValueCallback('Drupal\content_moderation\Entity\ContentModerationState::getCurrentUserId')
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['workflow'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Workflow'))
->setDescription(t('The workflow the moderation state is in.'))
->setSetting('target_type', 'workflow')
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['moderation_state'] = BaseFieldDefinition::create('string')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of the referenced content.'))
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE);
$fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Content entity type ID'))
->setDescription(t('The ID of the content entity type this moderation state is for.'))
->setRequired(TRUE)
->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
->setRevisionable(TRUE);
$fields['content_entity_id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Content entity ID'))
->setDescription(t('The ID of the content entity this moderation state is for.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Content entity revision ID'))
->setDescription(t('The revision ID of the content entity this moderation state is for.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
return $fields;
}
/**
* {@inheritdoc}
*/
public function getOwner() {
return $this->get('uid')->entity;
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->getEntityKey('uid');
}
/**
* {@inheritdoc}
*/
public function setOwnerId($uid) {
$this->set('uid', $uid);
return $this;
}
/**
* {@inheritdoc}
*/
public function setOwner(UserInterface $account) {
$this->set('uid', $account->id());
return $this;
}
/**
* Creates or updates an entity's moderation state whilst saving that entity.
*
* @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state
* The content moderation entity content entity to create or save.
*
* @internal
* This method should only be called as a result of saving the related
* content entity.
*/
public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) {
$content_moderation_state->realSave();
}
/**
* Loads a content moderation state entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* A moderated entity object.
*
* @return \Drupal\content_moderation\Entity\ContentModerationStateInterface|null
* The related content moderation state or NULL if none could be found.
*
* @internal
* This method should only be called by code directly handling the
* ContentModerationState entity objects.
*/
public static function loadFromModeratedEntity(EntityInterface $entity) {
$content_moderation_state = NULL;
$moderation_info = \Drupal::service('content_moderation.moderation_information');
if ($moderation_info->isModeratedEntity($entity)) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$storage = \Drupal::entityTypeManager()->getStorage('content_moderation_state');
$ids = $storage->getQuery()
->condition('content_entity_type_id', $entity->getEntityTypeId())
->condition('content_entity_id', $entity->id())
->condition('workflow', $moderation_info->getWorkflowForEntity($entity)->id())
->condition('content_entity_revision_id', $entity->getLoadedRevisionId())
->allRevisions()
->execute();
if ($ids) {
/** @var \Drupal\content_moderation\Entity\ContentModerationStateInterface $content_moderation_state */
$content_moderation_state = $storage->loadRevision(key($ids));
}
}
return $content_moderation_state;
}
/**
* Default value callback for the 'uid' base field definition.
*
* @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
*
* @return array
* An array of default values.
*/
public static function getCurrentUserId() {
return [\Drupal::currentUser()->id()];
}
/**
* {@inheritdoc}
*/
public function save() {
$related_entity = \Drupal::entityTypeManager()
->getStorage($this->content_entity_type_id->value)
->loadRevision($this->content_entity_revision_id->value);
if ($related_entity instanceof TranslatableInterface) {
$related_entity = $related_entity->getTranslation($this->activeLangcode);
}
$related_entity->moderation_state = $this->moderation_state;
return $related_entity->save();
}
/**
* Saves an entity permanently.
*
* When saving existing entities, the entity is assumed to be complete,
* partial updates of entities are not supported.
*
* @return int
* Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
*
* @throws \Drupal\Core\Entity\EntityStorageException
* In case of failures an exception is thrown.
*/
protected function realSave() {
return parent::save();
}
/**
* {@inheritdoc}
*/
protected function getFieldsToSkipFromTranslationChangesCheck() {
$field_names = parent::getFieldsToSkipFromTranslationChangesCheck();
// We need to skip the parent entity revision ID, since that will always
// change on every save, otherwise every translation would be marked as
// affected regardless of actual changes.
$field_names[] = 'content_entity_revision_id';
return $field_names;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace Drupal\content_moderation\Entity;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\user\EntityOwnerInterface;
/**
* An interface for Content moderation state entity.
*
* Content moderation state entities track the moderation state of other content
* entities.
*
* @internal
*/
interface ContentModerationStateInterface extends ContentEntityInterface, EntityOwnerInterface {
}

View file

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

View file

@ -0,0 +1,57 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Common customizations for most/all entities.
*
* This class is intended primarily as a base class.
*
* @internal
*/
class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static();
}
/**
* {@inheritdoc}
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
// This is probably not necessary if configuration is setup correctly.
$entity->setNewRevision(TRUE);
$entity->isDefaultRevision($default_revision);
// Update publishing status if it can be updated and if it needs updating.
if (($entity instanceof EntityPublishedInterface) && $entity->isPublished() !== $published_state) {
$published_state ? $entity->setPublished() : $entity->setUnpublished();
}
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
/**
* {@inheritdoc}
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Drupal\content_moderation\Entity\Handler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Defines operations that need to vary by entity type.
*
* Much of the logic contained in this handler is an indication of flaws
* in the Entity API that are insufficiently standardized between entity types.
* Hopefully over time functionality can be removed from this interface.
*
* @internal
*/
interface ModerationHandlerInterface {
/**
* Operates on moderated content entities preSave().
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to modify.
* @param bool $default_revision
* Whether the new revision should be made the default revision.
* @param bool $published_state
* Whether the state being transitioned to is a published state or not.
*/
public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state);
/**
* Alters entity forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id);
/**
* Alters bundle forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id);
}

View file

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

View file

@ -0,0 +1,123 @@
<?php
namespace Drupal\content_moderation\Entity\Routing;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Dynamic route provider for the Content moderation module.
*
* Provides the following routes:
* - The latest version tab, showing the latest revision of an entity, not the
* default one.
*
* @internal
*/
class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
/**
* The entity 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 any unpublished content" permission.
->setRequirement('_entity_access', "{$entity_type_id}.view")
->setRequirement('_content_moderation_latest_version', 'TRUE')
->setOption('_content_moderation_entity_type', $entity_type_id)
->setOption('parameters', [
$entity_type_id => [
'type' => 'entity:' . $entity_type_id,
'load_latest_revision' => TRUE,
],
]);
// Entity types with serial IDs can specify this in their route
// requirements, improving the matching process.
if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
$route->setRequirement($entity_type_id, '\d+');
}
return $route;
}
}
/**
* Gets the type of the ID key for a given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* An entity type.
*
* @return string|null
* The type of the ID key for a given entity type, or NULL if the entity
* type does not support fields.
*/
protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) {
if (!$entity_type->entityClassImplements(FieldableEntityInterface::class)) {
return NULL;
}
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
return $field_storage_definitions[$entity_type->getKey('id')]->getType();
}
}

View file

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

View file

@ -0,0 +1,393 @@
<?php
namespace Drupal\content_moderation;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Entity\BundleEntityFormBase;
use Drupal\Core\Entity\ContentEntityFormInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\content_moderation\Entity\Handler\BlockContentModerationHandler;
use Drupal\content_moderation\Entity\Handler\ModerationHandler;
use Drupal\content_moderation\Entity\Handler\NodeModerationHandler;
use Drupal\content_moderation\Entity\Routing\EntityModerationRouteProvider;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Manipulates entity type information.
*
* This class contains primarily bridged hooks for compile-time or
* cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
*
* @internal
*/
class EntityTypeInfo implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The bundle information service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $validator;
/**
* A keyed array of custom moderation handlers for given entity types.
*
* Any entity not specified will use a common default.
*
* @var array
*/
protected $moderationHandlers = [
'node' => NodeModerationHandler::class,
'block_content' => BlockContentModerationHandler::class,
];
/**
* EntityTypeInfo constructor.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The translation service. for form alters.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info
* Bundle information service.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user.
*/
public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user, StateTransitionValidationInterface $validator) {
$this->stringTranslation = $translation;
$this->moderationInfo = $moderation_information;
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->currentUser = $current_user;
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('string_translation'),
$container->get('content_moderation.moderation_information'),
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('current_user'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* Adds Moderation configuration to appropriate entity types.
*
* @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
* The master entity type list to alter.
*
* @see hook_entity_type_alter()
*/
public function entityTypeAlter(array &$entity_types) {
foreach ($entity_types as $entity_type_id => $entity_type) {
// The ContentModerationState entity type should never be moderated.
if ($entity_type->isRevisionable() && !$entity_type->isInternal()) {
$entity_types[$entity_type_id] = $this->addModerationToEntityType($entity_type);
}
}
}
/**
* Modifies an entity definition to include moderation support.
*
* This primarily just means an extra handler. A Generic one is provided,
* but individual entity types can provide their own as appropriate.
*
* @param \Drupal\Core\Entity\ContentEntityTypeInterface $type
* The content entity definition to modify.
*
* @return \Drupal\Core\Entity\ContentEntityTypeInterface
* The modified content entity definition.
*/
protected function addModerationToEntityType(ContentEntityTypeInterface $type) {
if (!$type->hasHandlerClass('moderation')) {
$handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class;
$type->setHandlerClass('moderation', $handler_class);
}
if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) {
$type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest');
}
$providers = $type->getRouteProviderClasses() ?: [];
if (empty($providers['moderation'])) {
$providers['moderation'] = EntityModerationRouteProvider::class;
$type->setHandlerClass('route_provider', $providers);
}
return $type;
}
/**
* Gets the "extra fields" for a bundle.
*
* @return array
* A nested array of 'pseudo-field' elements. Each list is nested within the
* following keys: entity type, bundle name, context (either 'form' or
* 'display'). The keys are the name of the elements as appearing in the
* renderable array (either the entity form or the displayed entity). The
* value is an associative array:
* - label: The human readable name of the element. Make sure you sanitize
* this appropriately.
* - description: A short description of the element contents.
* - weight: The default weight of the element.
* - visible: (optional) The default visibility of the element. Defaults to
* TRUE.
* - edit: (optional) String containing markup (normally a link) used as the
* element's 'edit' operation in the administration interface. Only for
* 'form' context.
* - delete: (optional) String containing markup (normally a link) used as
* the element's 'delete' operation in the administration interface. Only
* for 'form' context.
*
* @see hook_entity_extra_field_info()
*/
public function entityExtraFieldInfo() {
$return = [];
foreach ($this->getModeratedBundles() as $bundle) {
$return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [
'label' => $this->t('Moderation control'),
'description' => $this->t("Status listing and form for the entity's moderation state."),
'weight' => -20,
'visible' => TRUE,
];
}
return $return;
}
/**
* Returns an iterable list of entity names and bundle names under moderation.
*
* That is, this method returns a list of bundles that have Content
* Moderation enabled on them.
*
* @return \Generator
* A generator, yielding a 2 element associative array:
* - entity: The machine name of an entity type, such as "node" or
* "block_content".
* - bundle: The machine name of a bundle, such as "page" or "article".
*/
protected function getModeratedBundles() {
$entity_types = array_filter($this->entityTypeManager->getDefinitions(), [$this->moderationInfo, 'canModerateEntitiesOfEntityType']);
foreach ($entity_types as $type_name => $type) {
foreach ($this->bundleInfo->getBundleInfo($type_name) as $bundle_id => $bundle) {
if ($this->moderationInfo->shouldModerateEntitiesOfBundle($type, $bundle_id)) {
yield ['entity' => $type_name, 'bundle' => $bundle_id];
}
}
}
}
/**
* Adds base field info to an entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* Entity type for adding base fields to.
*
* @return \Drupal\Core\Field\BaseFieldDefinition[]
* New fields added by moderation state.
*
* @see hook_entity_base_field_info()
*/
public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
if (!$this->moderationInfo->canModerateEntitiesOfEntityType($entity_type)) {
return [];
}
$fields = [];
$fields['moderation_state'] = BaseFieldDefinition::create('string')
->setLabel(t('Moderation state'))
->setDescription(t('The moderation state of this piece of content.'))
->setComputed(TRUE)
->setClass(ModerationStateFieldItemList::class)
->setDisplayOptions('view', [
'label' => 'hidden',
'region' => 'hidden',
'weight' => -5,
])
->setDisplayOptions('form', [
'type' => 'moderation_state_default',
'weight' => 100,
'settings' => [],
])
->addConstraint('ModerationState', [])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', FALSE)
->setReadOnly(FALSE)
->setTranslatable(TRUE);
return $fields;
}
/**
* Replaces the entity form entity object with a proper revision object.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being edited.
* @param string $operation
* The entity form operation.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @see hook_entity_prepare_form()
*/
public function entityPrepareForm(EntityInterface $entity, $operation, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\EntityFormInterface $form_object */
$form_object = $form_state->getFormObject();
if ($this->isModeratedEntityEditForm($form_object) && !$entity->isNew()) {
// Generate a proper revision object for the current entity. This allows
// to correctly handle translatable entities having pending revisions.
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
/** @var \Drupal\Core\Entity\ContentEntityInterface $new_revision */
$new_revision = $storage->createRevision($entity, FALSE);
// Restore the revision ID as other modules may expect to find it still
// populated. This will reset the "new revision" flag, however the entity
// object will be marked as a new revision again on submit.
// @see \Drupal\Core\Entity\ContentEntityForm::buildEntity()
$revision_key = $new_revision->getEntityType()->getKey('revision');
$new_revision->set($revision_key, $new_revision->getLoadedRevisionId());
$form_object->setEntity($new_revision);
}
}
/**
* Alters bundle forms to enforce revision handling.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form id.
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form_object = $form_state->getFormObject();
if ($form_object instanceof BundleEntityFormBase) {
$config_entity_type = $form_object->getEntity()->getEntityType();
$bundle_of = $config_entity_type->getBundleOf();
if ($bundle_of
&& ($bundle_of_entity_type = $this->entityTypeManager->getDefinition($bundle_of))
&& $this->moderationInfo->canModerateEntitiesOfEntityType($bundle_of_entity_type)) {
$this->entityTypeManager->getHandler($config_entity_type->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
}
}
elseif ($this->isModeratedEntityEditForm($form_object)) {
/** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_object->getEntity();
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'];
// Move the 'moderation_state' field widget to the footer region, if
// available.
if (isset($form['footer'])) {
$form['moderation_state']['#group'] = 'footer';
}
// If the publishing status exists in the meta region, replace it with
// the current state instead.
if (isset($form['meta']['published'])) {
$form['meta']['published']['#markup'] = $this->moderationInfo->getWorkflowForEntity($entity)->getTypePlugin()->getState($entity->moderation_state->value)->label();
}
}
}
}
/**
* Checks whether the specified form allows to edit a moderated entity.
*
* @param \Drupal\Core\Form\FormInterface $form_object
* The form object.
*
* @return bool
* TRUE if the form should get form moderation, FALSE otherwise.
*/
protected function isModeratedEntityEditForm(FormInterface $form_object) {
return $form_object instanceof ContentEntityFormInterface &&
in_array($form_object->getOperation(), ['edit', 'default'], TRUE) &&
$this->moderationInfo->isModeratedEntity($form_object->getEntity());
}
/**
* Redirect content entity edit forms on save, if there is a pending revision.
*
* When saving their changes, editors should see those changes displayed on
* the next page.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) {
/* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
$moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information');
if ($moderation_info->hasPendingRevision($entity) && $entity->hasLinkTemplate('latest-version')) {
$entity_type_id = $entity->getEntityTypeId();
$form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]);
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,142 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Component\Serialization\Json;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\workflows\Plugin\WorkflowTypeConfigureFormBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The content moderation WorkflowType configuration form.
*
* @see \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationConfigureForm extends WorkflowTypeConfigureFormBase implements ContainerInjectionInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The moderation info service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The entity type type bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $entityTypeBundleInfo;
/**
* Create an instance of ContentModerationConfigureForm.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager, ModerationInformationInterface $moderationInformation, EntityTypeBundleInfoInterface $entityTypeBundleInfo) {
$this->entityTypeManager = $entityTypeManager;
$this->moderationInfo = $moderationInformation;
$this->entityTypeBundleInfo = $entityTypeBundleInfo;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.moderation_information'),
$container->get('entity_type.bundle.info')
);
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$workflow = $form_state->getFormObject()->getEntity();
$header = [
'type' => $this->t('Items'),
'operations' => $this->t('Operations'),
];
$form['entity_types_container'] = [
'#type' => 'details',
'#title' => $this->t('This workflow applies to:'),
'#open' => TRUE,
];
$form['entity_types_container']['entity_types'] = [
'#type' => 'table',
'#header' => $header,
'#empty' => $this->t('There are no entity types.'),
];
$entity_types = $this->entityTypeManager->getDefinitions();
foreach ($entity_types as $entity_type) {
if (!$this->moderationInfo->canModerateEntitiesOfEntityType($entity_type)) {
continue;
}
$selected_bundles = [];
foreach ($this->entityTypeBundleInfo->getBundleInfo($entity_type->id()) as $bundle_id => $bundle) {
if ($this->workflowType->appliesToEntityTypeAndBundle($entity_type->id(), $bundle_id)) {
$selected_bundles[$bundle_id] = $bundle['label'];
}
}
$selected_bundles_list = [
'#theme' => 'item_list',
'#items' => $selected_bundles,
'#context' => ['list_style' => 'comma-list'],
'#empty' => $this->t('none'),
];
$form['entity_types_container']['entity_types'][$entity_type->id()] = [
'type' => [
'#type' => 'inline_template',
'#template' => '<strong>{{ label }}</strong></br><span id="selected-{{ entity_type_id }}">{{ selected_bundles }}</span>',
'#context' => [
'label' => $this->t('@bundle types', ['@bundle' => $entity_type->getLabel()]),
'entity_type_id' => $entity_type->id(),
'selected_bundles' => $selected_bundles_list,
],
],
'operations' => [
'#type' => 'operations',
'#links' => [
'select' => [
'title' => $this->t('Select'),
'url' => Url::fromRoute('content_moderation.workflow_type_edit_form', ['workflow' => $workflow->id(), 'entity_type_id' => $entity_type->id()]),
'attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'width' => 700,
]),
],
],
],
],
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
// Configuration is updated from modal windows launched from this form, no
// need to change any configuration here.
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workflows\Plugin\WorkflowTypeStateFormBase;
use Drupal\workflows\StateInterface;
/**
* The content moderation state form.
*
* @see \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationStateForm extends WorkflowTypeStateFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state, StateInterface $state = NULL) {
/** @var \Drupal\content_moderation\ContentModerationState $state */
$state = $form_state->get('state');
$is_required_state = isset($state) ? in_array($state->id(), $this->workflowType->getRequiredStates(), TRUE) : FALSE;
$form = [];
$form['published'] = [
'#type' => 'checkbox',
'#title' => $this->t('Published'),
'#description' => $this->t('When content reaches this state it should be published.'),
'#default_value' => isset($state) ? $state->isPublishedState() : FALSE,
'#disabled' => $is_required_state,
];
$form['default_revision'] = [
'#type' => 'checkbox',
'#title' => $this->t('Default revision'),
'#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'),
'#default_value' => isset($state) ? $state->isDefaultRevisionState() : FALSE,
'#disabled' => $is_required_state,
// @todo Add form #state to force "make default" on when "published" is
// on for a state.
// @see https://www.drupal.org/node/2645614
];
return $form;
}
}

View file

@ -0,0 +1,169 @@
<?php
namespace Drupal\content_moderation\Form;
use Drupal\Component\Datetime\Time;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\workflows\Transition;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The EntityModerationForm provides a simple UI for changing moderation state.
*
* @internal
*/
class EntityModerationForm extends FormBase {
/**
* The moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInfo;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\Time
*/
protected $time;
/**
* The moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $validation;
/**
* EntityModerationForm constructor.
*
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
* The moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $validation
* The moderation state transition validation service.
* @param \Drupal\Component\Datetime\Time $time
* The time service.
*/
public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidationInterface $validation, Time $time) {
$this->moderationInfo = $moderation_info;
$this->validation = $validation;
$this->time = $time;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation'),
$container->get('datetime.time')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'content_moderation_entity_moderation_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) {
$current_state = $entity->moderation_state->value;
$workflow = $this->moderationInfo->getWorkflowForEntity($entity);
/** @var \Drupal\workflows\Transition[] $transitions */
$transitions = $this->validation->getValidTransitions($entity, $this->currentUser());
// Exclude self-transitions.
$transitions = array_filter($transitions, function (Transition $transition) use ($current_state) {
return $transition->to()->id() != $current_state;
});
$target_states = [];
foreach ($transitions as $transition) {
$target_states[$transition->to()->id()] = $transition->to()->label();
}
if (!count($target_states)) {
return $form;
}
if ($current_state) {
$form['current'] = [
'#type' => 'item',
'#title' => $this->t('Moderation state'),
'#markup' => $workflow->getTypePlugin()->getState($current_state)->label(),
];
}
// Persist the entity so we can access it in the submit handler.
$form_state->set('entity', $entity);
$form['new_state'] = [
'#type' => 'select',
'#title' => $this->t('Change to'),
'#options' => $target_states,
];
$form['revision_log'] = [
'#type' => 'textfield',
'#title' => $this->t('Log message'),
'#size' => 30,
];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Apply'),
];
$form['#theme'] = ['entity_moderation_form'];
$form['#attached']['library'][] = 'content_moderation/content_moderation';
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $form_state->get('entity');
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
$entity = $storage->createRevision($entity, $entity->isDefaultRevision());
$new_state = $form_state->getValue('new_state');
$entity->set('moderation_state', $new_state);
if ($entity instanceof RevisionLogInterface) {
$entity->setRevisionCreationTime($this->time->getRequestTime());
$entity->setRevisionLogMessage($form_state->getValue('revision_log'));
$entity->setRevisionUserId($this->currentUser()->id());
}
$entity->save();
$this->messenger()->addStatus($this->t('The moderation state has been updated.'));
$new_state = $this->moderationInfo->getWorkflowForEntity($entity)->getTypePlugin()->getState($new_state);
// The page we're on likely won't be visible if we just set the entity to
// the default state, as we hide that latest-revision tab if there is no
// pending revision. Redirect to the canonical URL instead, since that will
// still exist.
if ($new_state->isDefaultRevisionState()) {
$form_state->setRedirectUrl($entity->toUrl('canonical'));
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,177 @@
<?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);
/**
* Returns the revision translation affected translation of a revision.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The content entity.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The revision translation affected translation.
*/
public function getAffectedRevisionTranslation(ContentEntityInterface $entity);
/**
* Determines if 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 pending revision exists for the specified entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity which may or may not have a pending revision.
*
* @return bool
* TRUE if this entity has pending revisions available, FALSE otherwise.
*/
public function hasPendingRevision(ContentEntityInterface $entity);
/**
* Determines if an entity is "live".
*
* A "live" entity revision is one whose latest revision is also the default,
* and whose moderation state, if any, is a published state.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if the specified entity is a live revision, FALSE otherwise.
*/
public function isLiveRevision(ContentEntityInterface $entity);
/**
* Determines if the default revision for the given entity is published.
*
* The default revision is the same as the entity retrieved by "default" from
* the storage handler. If the entity is translated, check if any of the
* translations are published.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity being saved.
*
* @return bool
* TRUE if the default revision is published. FALSE otherwise.
*/
public function isDefaultRevisionPublished(ContentEntityInterface $entity);
/**
* Gets the workflow for the given content entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The content entity to get the workflow for.
*
* @return \Drupal\workflows\WorkflowInterface|null
* The workflow entity. NULL if there is no workflow.
*/
public function getWorkflowForEntity(ContentEntityInterface $entity);
/**
* Gets unsupported features for a given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to get the unsupported features for.
*
* @return array
* An array of unsupported features for this entity type.
*/
public function getUnsupportedFeatures(EntityTypeInterface $entity_type);
}

View file

@ -0,0 +1,30 @@
<?php
namespace Drupal\content_moderation\ParamConverter;
use Drupal\Core\ParamConverter\EntityConverter;
/**
* Defines a class for making sure the edit-route loads the current draft.
*
* @internal
* This class only exists to provide backwards compatibility with the
* load_pending_revision flag, the predecessor to load_latest_revision. The
* core entity converter now natively loads the latest revision of an entity
* when the load_latest_revision flag is present. This flag is also added
* automatically to all entity forms.
*/
class EntityRevisionConverter extends EntityConverter {
/**
* {@inheritdoc}
*/
public function convert($value, $definition, $name, array $defaults) {
if (!empty($definition['load_pending_revision'])) {
@trigger_error('The load_pending_revision flag has been deprecated. You should use load_latest_revision instead.', E_USER_DEPRECATED);
$definition['load_latest_revision'] = TRUE;
}
return parent::convert($value, $definition, $name, $defaults);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workflows\Entity\Workflow;
/**
* Defines a class for dynamic permissions based on transitions.
*
* @internal
*/
class Permissions {
use StringTranslationTrait;
/**
* Returns an array of transition permissions.
*
* @return array
* The transition permissions.
*/
public function transitionPermissions() {
$permissions = [];
/** @var \Drupal\workflows\WorkflowInterface $workflow */
foreach (Workflow::loadMultipleByType('content_moderation') as $id => $workflow) {
foreach ($workflow->getTypePlugin()->getTransitions() as $transition) {
$permissions['use ' . $workflow->id() . ' transition ' . $transition->id()] = [
'title' => $this->t('%workflow workflow: Use %transition transition.', [
'%workflow' => $workflow->label(),
'%transition' => $transition->label(),
]),
];
}
}
return $permissions;
}
}

View file

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

View file

@ -0,0 +1,29 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @deprecated in Drupal 8.5.x, to be removed before Drupal 9.0.0.
* Use \Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish
* instead.
*
* @see \Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish
* @see https://www.drupal.org/node/2919303
*/
class ModerationOptOutPublishNode extends ModerationOptOutPublish {
/**
* {@inheritdoc}
*/
public function __construct($configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_info, EntityTypeBundleInfoInterface $bundle_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $moderation_info, $bundle_info);
@trigger_error(__NAMESPACE__ . '\ModerationOptOutPublishNode is deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. Use \Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish instead. See https://www.drupal.org/node/2919303.', E_USER_DEPRECATED);
}
}

View file

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

View file

@ -0,0 +1,29 @@
<?php
namespace Drupal\content_moderation\Plugin\Action;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Alternate action plugin that can opt-out of modifying moderated entities.
*
* @deprecated in Drupal 8.5.x, to be removed before Drupal 9.0.0.
* Use \Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublish
* instead.
*
* @see \Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish
* @see https://www.drupal.org/node/2919303
*/
class ModerationOptOutUnpublishNode extends ModerationOptOutUnpublish {
/**
* {@inheritdoc}
*/
public function __construct($configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_info, EntityTypeBundleInfoInterface $bundle_info) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $moderation_info, $bundle_info);
@trigger_error(__NAMESPACE__ . '\ModerationOptOutUnpublishNode is deprecated in Drupal 8.5.x, will be removed before Drupal 9.0.0. Use \Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublish instead. See https://www.drupal.org/node/2919303.', E_USER_DEPRECATED);
}
}

View file

@ -0,0 +1,95 @@
<?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 = [];
$latest_version_entities = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInfo->canModerateEntitiesOfEntityType($type) && $type->hasLinkTemplate('latest-version');
});
foreach ($latest_version_entities as $entity_type_id => $entity_type) {
$this->derivatives["$entity_type_id.latest_version_tab"] = [
'route_name' => "entity.$entity_type_id.latest_version",
'title' => $this->t('Latest version'),
'base_route' => "entity.$entity_type_id.canonical",
'weight' => 1,
] + $base_plugin_definition;
}
return $this->derivatives;
}
}

View file

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

View file

@ -0,0 +1,181 @@
<?php
namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\content_moderation\ModerationInformation;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'moderation_state_default' widget.
*
* @FieldWidget(
* id = "moderation_state_default",
* label = @Translation("Moderation state"),
* field_types = {
* "string"
* }
* )
*/
class ModerationStateWidget extends OptionsSelectWidget implements ContainerFactoryPluginInterface {
/**
* Current user service.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Moderation information service.
*
* @var \Drupal\content_moderation\ModerationInformation
*/
protected $moderationInformation;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Moderation state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $validator;
/**
* Constructs a new ModerationStateWidget object.
*
* @param string $plugin_id
* Plugin id.
* @param mixed $plugin_definition
* Plugin definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* Field definition.
* @param array $settings
* Field settings.
* @param array $third_party_settings
* Third party settings.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\content_moderation\ModerationInformation $moderation_information
* Moderation information service.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $validator
* Moderation state transition validation service.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, ModerationInformation $moderation_information, StateTransitionValidationInterface $validator) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->entityTypeManager = $entity_type_manager;
$this->currentUser = $current_user;
$this->moderationInformation = $moderation_information;
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('current_user'),
$container->get('entity_type.manager'),
$container->get('content_moderation.moderation_information'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@inheritdoc}
*/
public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) {
$entity = $items->getEntity();
if (!$this->moderationInformation->isModeratedEntity($entity)) {
return [];
}
return parent::form($items, $form, $form_state, $get_delta);
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $items->getEntity();
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
$default = $items->get($delta)->value ? $workflow->getTypePlugin()->getState($items->get($delta)->value) : $workflow->getTypePlugin()->getInitialState($entity);
/** @var \Drupal\workflows\Transition[] $transitions */
$transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
$transition_labels = [];
$default_value = $items->value;
foreach ($transitions as $transition) {
$transition_to_state = $transition->to();
$transition_labels[$transition_to_state->id()] = $transition_to_state->label();
if ($default->id() === $transition_to_state->id()) {
$default_value = $default->id();
}
}
$element += [
'#type' => 'container',
'current' => [
'#type' => 'item',
'#title' => $this->t('Current state'),
'#markup' => $default->label(),
'#access' => !$entity->isNew(),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
],
'state' => [
'#type' => 'select',
'#title' => $entity->isNew() ? $this->t('Save as') : $this->t('Change to'),
'#key_column' => $this->column,
'#options' => $transition_labels,
'#default_value' => $default_value,
'#access' => !empty($transition_labels),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
],
];
$element['#element_validate'][] = [get_class($this), 'validateElement'];
return $element;
}
/**
* {@inheritdoc}
*/
public static function validateElement(array $element, FormStateInterface $form_state) {
$form_state->setValueForElement($element, [$element['state']['#key_column'] => $element['state']['#value']]);
}
/**
* {@inheritdoc}
*/
public static function isApplicable(FieldDefinitionInterface $field_definition) {
return is_a($field_definition->getClass(), ModerationStateFieldItemList::class, TRUE);
}
}

View file

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

View file

@ -0,0 +1,21 @@
<?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';
public $invalidStateMessage = 'State %state does not exist on %workflow workflow';
public $invalidTransitionAccess = 'You do not have access to transition from %original_state to %new_state';
}

View file

@ -0,0 +1,189 @@
<?php
namespace Drupal\content_moderation\Plugin\Validation\Constraint;
use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Validation\Plugin\Validation\Constraint\NotNullConstraint;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if a moderation state transition is valid.
*/
class ModerationStateConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The moderation info.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The state transition validation service.
*
* @var \Drupal\content_moderation\StateTransitionValidationInterface
*/
protected $stateTransitionValidation;
/**
* Creates a new ModerationStateConstraintValidator instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\content_moderation\StateTransitionValidationInterface $state_transition_validation
* The state transition validation service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information, AccountInterface $current_user, StateTransitionValidationInterface $state_transition_validation) {
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
$this->currentUser = $current_user;
$this->stateTransitionValidation = $state_transition_validation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('content_moderation.moderation_information'),
$container->get('current_user'),
$container->get('content_moderation.state_transition_validation')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $value->getEntity();
// Ignore entities that are not subject to moderation anyway.
if (!$this->moderationInformation->isModeratedEntity($entity)) {
return;
}
// If the entity is moderated and the item list is empty, ensure users see
// the same required message as typical NotNull constraints.
if ($value->isEmpty()) {
$this->context->addViolation((new NotNullConstraint())->message);
return;
}
$workflow = $this->moderationInformation->getWorkflowForEntity($entity);
if (!$workflow->getTypePlugin()->hasState($entity->moderation_state->value)) {
// If the state we are transitioning to doesn't exist, we can't validate
// the transitions for this entity further.
$this->context->addViolation($constraint->invalidStateMessage, [
'%state' => $entity->moderation_state->value,
'%workflow' => $workflow->label(),
]);
return;
}
$new_state = $workflow->getTypePlugin()->getState($entity->moderation_state->value);
$original_state = $this->getOriginalOrInitialState($entity);
// If a new state is being set and there is an existing state, validate
// there is a valid transition between them.
if (!$original_state->canTransitionTo($new_state->id())) {
$this->context->addViolation($constraint->message, [
'%from' => $original_state->label(),
'%to' => $new_state->label(),
]);
}
else {
// If we're sure the transition exists, make sure the user has permission
// to use it.
if (!$this->stateTransitionValidation->isTransitionValid($workflow, $original_state, $new_state, $this->currentUser)) {
$this->context->addViolation($constraint->invalidTransitionAccess, [
'%original_state' => $original_state->label(),
'%new_state' => $new_state->label(),
]);
}
}
}
/**
* Gets the original or initial state of the given entity.
*
* When a state is being validated, the original state is used to validate
* that a valid transition exists for target state and the user has access
* to the transition between those two states. If the entity has been
* moderated before, we can load the original unmodified revision and
* translation for this state.
*
* If the entity is new we need to load the initial state from the workflow.
* Even if a value was assigned to the moderation_state field, the initial
* state is used to compute an appropriate transition for the purposes of
* validation.
*
* @return \Drupal\workflows\StateInterface
* The original or default moderation state.
*/
protected function getOriginalOrInitialState(ContentEntityInterface $entity) {
$state = NULL;
$workflow_type = $this->moderationInformation->getWorkflowForEntity($entity)->getTypePlugin();
if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) {
$original_entity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadRevision($entity->getLoadedRevisionId());
if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
$original_entity = $original_entity->getTranslation($entity->language()->getId());
}
if ($workflow_type->hasState($original_entity->moderation_state->value)) {
$state = $workflow_type->getState($original_entity->moderation_state->value);
}
}
return $state ?: $workflow_type->getInitialState($entity);
}
/**
* Determines if this entity is being moderated for the first time.
*
* If the previous version of the entity has no moderation state, we assume
* that means it predates the presence of moderation states.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being moderated.
*
* @return bool
* TRUE if this is the entity's first time being moderated, FALSE otherwise.
*/
protected function isFirstTimeModeration(EntityInterface $entity) {
$original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
if ($original_entity) {
$original_id = $original_entity->moderation_state;
}
return !($entity->moderation_state && $original_entity && $original_id);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,117 @@
<?php
namespace Drupal\content_moderation;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Provides the content_moderation views integration.
*
* @internal
*/
class ViewsData {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The moderation information.
*
* @var \Drupal\content_moderation\ModerationInformationInterface
*/
protected $moderationInformation;
/**
* Creates a new ViewsData instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
* The moderation information.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information) {
$this->entityTypeManager = $entity_type_manager;
$this->moderationInformation = $moderation_information;
}
/**
* Returns the views data.
*
* @return array
* The views data.
*/
public function getViewsData() {
$data = [];
$entity_types_with_moderation = array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
return $this->moderationInformation->canModerateEntitiesOfEntityType($type);
});
// Provides a relationship from moderated entity to its moderation state
// entity.
$content_moderation_state_entity_type = $this->entityTypeManager->getDefinition('content_moderation_state');
$content_moderation_state_entity_base_table = $content_moderation_state_entity_type->getDataTable() ?: $content_moderation_state_entity_type->getBaseTable();
$content_moderation_state_entity_revision_base_table = $content_moderation_state_entity_type->getRevisionDataTable() ?: $content_moderation_state_entity_type->getRevisionTable();
foreach ($entity_types_with_moderation as $entity_type_id => $entity_type) {
$table = $entity_type->getDataTable() ?: $entity_type->getBaseTable();
$data[$table]['moderation_state'] = [
'title' => t('Moderation state'),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
'base' => $content_moderation_state_entity_base_table,
'base field' => 'content_entity_id',
'relationship field' => $entity_type->getKey('id'),
'extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
],
],
'field' => [
'id' => 'field',
'default_formatter' => 'content_moderation_state',
'field_name' => 'moderation_state',
],
'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE],
];
$revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
$data[$revision_table]['moderation_state'] = [
'title' => t('Moderation state'),
'relationship' => [
'id' => 'standard',
'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
'base' => $content_moderation_state_entity_revision_base_table,
'base field' => 'content_entity_revision_id',
'relationship field' => $entity_type->getKey('revision'),
'extra' => [
[
'field' => 'content_entity_type_id',
'value' => $entity_type_id,
],
],
],
'field' => [
'id' => 'field',
'default_formatter' => 'content_moderation_state',
'field_name' => 'moderation_state',
],
'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE],
];
}
return $data;
}
}

View file

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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,804 @@
<?php
// @codingStandardsIgnoreFile
/**
* @file
* Content for the update path test in #2941736.
*
* @see \Drupal\Tests\content_moderation\Functional\DefaultContentModerationStateRevisionUpdateTest.
*/
use Drupal\Core\Database\Database;
$connection = Database::getConnection();
$connection->insert('block_content')
->fields(array(
'id',
'revision_id',
'type',
'uuid',
'langcode',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'type' => 'test_block_content',
'uuid' => '811fac6c-8184-4de5-99eb-9e70d28709f4',
'langcode' => 'en',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'type' => 'test_block_content',
'uuid' => 'b89f025c-0538-4075-bd8e-96acf74211c9',
'langcode' => 'en',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'type' => 'test_block_content',
'uuid' => '62e428e1-88a6-478c-a8c6-a554ca2332ae',
'langcode' => 'en',
))
->execute();
$connection->insert('block_content_field_data')
->fields(array(
'id',
'revision_id',
'type',
'langcode',
'info',
'changed',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'type' => 'test_block_content',
'langcode' => 'en',
'info' => 'draft pending revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'type' => 'test_block_content',
'langcode' => 'en',
'info' => 'published default revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'type' => 'test_block_content',
'langcode' => 'en',
'info' => 'archived default revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('block_content_field_revision')
->fields(array(
'id',
'revision_id',
'langcode',
'info',
'changed',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'langcode' => 'en',
'info' => 'draft pending revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '1',
'revision_id' => '2',
'langcode' => 'en',
'info' => 'draft pending revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'langcode' => 'en',
'info' => 'published default revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '4',
'langcode' => 'en',
'info' => 'archived default revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'langcode' => 'en',
'info' => 'archived default revision',
'changed' => '1517725800',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('block_content_revision')
->fields(array(
'id',
'revision_id',
'langcode',
'revision_user',
'revision_created',
'revision_log',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'langcode' => 'en',
'revision_user' => NULL,
'revision_created' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'id' => '1',
'revision_id' => '2',
'langcode' => 'en',
'revision_user' => NULL,
'revision_created' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'id' => '2',
'revision_id' => '3',
'langcode' => 'en',
'revision_user' => NULL,
'revision_created' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'id' => '3',
'revision_id' => '4',
'langcode' => 'en',
'revision_user' => NULL,
'revision_created' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'id' => '3',
'revision_id' => '5',
'langcode' => 'en',
'revision_user' => NULL,
'revision_created' => '1517725800',
'revision_log' => NULL,
))
->execute();
$connection->delete('config')
->condition('name', ['workflows.workflow.editorial'], 'IN')
->execute();
$connection->insert('config')
->fields(array(
'collection',
'name',
'data',
))
->values(array(
'collection' => '',
'name' => 'block_content.type.test_block_content',
'data' => 'a:8:{s:4:"uuid";s:36:"966baba6-525e-48fe-b8c5-a5f131b1857f";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:0:{}s:2:"id";s:18:"test_block_content";s:5:"label";s:18:"Test Block Content";s:8:"revision";N;s:11:"description";N;}',
))
->values(array(
'collection' => '',
'name' => 'workflows.workflow.editorial',
'data' => 'a:9:{s:4:"uuid";s:36:"08b548c7-ff59-468b-9347-7d697680d035";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:2:{i:0;s:37:"block_content.type.test_block_content";i:1;s:17:"node.type.article";}s:6:"module";a:1:{i:0;s:18:"content_moderation";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"T_JxNjYlfoRBi7Bj1zs5Xv9xv1btuBkKp5C1tNrjMhI";}s:2:"id";s:9:"editorial";s:5:"label";s:9:"Editorial";s:4:"type";s:18:"content_moderation";s:13:"type_settings";a:3:{s:6:"states";a:3:{s:8:"archived";a:4:{s:5:"label";s:8:"Archived";s:6:"weight";i:5;s:9:"published";b:0;s:16:"default_revision";b:1;}s:5:"draft";a:4:{s:5:"label";s:5:"Draft";s:9:"published";b:0;s:16:"default_revision";b:0;s:6:"weight";i:-5;}s:9:"published";a:4:{s:5:"label";s:9:"Published";s:9:"published";b:1;s:16:"default_revision";b:1;s:6:"weight";i:0;}}s:11:"transitions";a:5:{s:7:"archive";a:4:{s:5:"label";s:7:"Archive";s:4:"from";a:1:{i:0;s:9:"published";}s:2:"to";s:8:"archived";s:6:"weight";i:2;}s:14:"archived_draft";a:4:{s:5:"label";s:16:"Restore to Draft";s:4:"from";a:1:{i:0;s:8:"archived";}s:2:"to";s:5:"draft";s:6:"weight";i:3;}s:18:"archived_published";a:4:{s:5:"label";s:7:"Restore";s:4:"from";a:1:{i:0;s:8:"archived";}s:2:"to";s:9:"published";s:6:"weight";i:4;}s:16:"create_new_draft";a:4:{s:5:"label";s:16:"Create New Draft";s:2:"to";s:5:"draft";s:6:"weight";i:0;s:4:"from";a:2:{i:0;s:5:"draft";i:1;s:9:"published";}}s:7:"publish";a:4:{s:5:"label";s:7:"Publish";s:2:"to";s:9:"published";s:6:"weight";i:1;s:4:"from";a:2:{i:0;s:5:"draft";i:1;s:9:"published";}}}s:12:"entity_types";a:2:{s:13:"block_content";a:1:{i:0;s:18:"test_block_content";}s:4:"node";a:1:{i:0;s:7:"article";}}}}',
))
->execute();
$connection->insert('content_moderation_state')
->fields(array(
'id',
'revision_id',
'uuid',
'langcode',
))
->values(array(
'id' => '1',
'revision_id' => '2',
'uuid' => '3ce04732-f65f-4937-aa49-821f5842ae06',
'langcode' => 'en',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'uuid' => 'a6507b55-3001-4748-8d32-f4fa47319754',
'langcode' => 'en',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'uuid' => '112d2bd2-552b-4e2f-9a6d-526740ba1b38',
'langcode' => 'en',
))
->values(array(
'id' => '4',
'revision_id' => '7',
'uuid' => 'a85d0d06-e046-4509-b9b4-75d78dcdd91e',
'langcode' => 'en',
))
->values(array(
'id' => '5',
'revision_id' => '8',
'uuid' => '3797f5de-116b-4d75-b7e3-5206e6f97c41',
'langcode' => 'en',
))
->values(array(
'id' => '6',
'revision_id' => '10',
'uuid' => '8d9b11c1-8ddf-4c61-bb8d-9ac724e28d9e',
'langcode' => 'en',
))
->execute();
$connection->insert('content_moderation_state_field_data')
->fields(array(
'id',
'revision_id',
'langcode',
'uid',
'workflow',
'moderation_state',
'content_entity_type_id',
'content_entity_id',
'content_entity_revision_id',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'id' => '1',
'revision_id' => '2',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'draft',
'content_entity_type_id' => 'node',
'content_entity_id' => '1',
'content_entity_revision_id' => '2',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'node',
'content_entity_id' => '2',
'content_entity_revision_id' => '3',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'archived',
'content_entity_type_id' => 'node',
'content_entity_id' => '3',
'content_entity_revision_id' => '5',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '4',
'revision_id' => '7',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'draft',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '1',
'content_entity_revision_id' => '2',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '5',
'revision_id' => '8',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '2',
'content_entity_revision_id' => '3',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '6',
'revision_id' => '10',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'archived',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '3',
'content_entity_revision_id' => '5',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('content_moderation_state_field_revision')
->fields(array(
'id',
'revision_id',
'langcode',
'uid',
'workflow',
'moderation_state',
'content_entity_type_id',
'content_entity_id',
'content_entity_revision_id',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'node',
'content_entity_id' => '1',
'content_entity_revision_id' => '1',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '1',
'revision_id' => '2',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'draft',
'content_entity_type_id' => 'node',
'content_entity_id' => '1',
'content_entity_revision_id' => '2',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'node',
'content_entity_id' => '2',
'content_entity_revision_id' => '3',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '4',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'node',
'content_entity_id' => '3',
'content_entity_revision_id' => '4',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'archived',
'content_entity_type_id' => 'node',
'content_entity_id' => '3',
'content_entity_revision_id' => '5',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '4',
'revision_id' => '6',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '1',
'content_entity_revision_id' => '1',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '4',
'revision_id' => '7',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'draft',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '1',
'content_entity_revision_id' => '2',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '5',
'revision_id' => '8',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '2',
'content_entity_revision_id' => '3',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '6',
'revision_id' => '9',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'published',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '3',
'content_entity_revision_id' => '4',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'id' => '6',
'revision_id' => '10',
'langcode' => 'en',
'uid' => '0',
'workflow' => 'editorial',
'moderation_state' => 'archived',
'content_entity_type_id' => 'block_content',
'content_entity_id' => '3',
'content_entity_revision_id' => '5',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('content_moderation_state_revision')
->fields(array(
'id',
'revision_id',
'langcode',
))
->values(array(
'id' => '1',
'revision_id' => '1',
'langcode' => 'en',
))
->values(array(
'id' => '1',
'revision_id' => '2',
'langcode' => 'en',
))
->values(array(
'id' => '2',
'revision_id' => '3',
'langcode' => 'en',
))
->values(array(
'id' => '3',
'revision_id' => '4',
'langcode' => 'en',
))
->values(array(
'id' => '3',
'revision_id' => '5',
'langcode' => 'en',
))
->values(array(
'id' => '4',
'revision_id' => '6',
'langcode' => 'en',
))
->values(array(
'id' => '4',
'revision_id' => '7',
'langcode' => 'en',
))
->values(array(
'id' => '5',
'revision_id' => '8',
'langcode' => 'en',
))
->values(array(
'id' => '6',
'revision_id' => '9',
'langcode' => 'en',
))
->values(array(
'id' => '6',
'revision_id' => '10',
'langcode' => 'en',
))
->execute();
$connection->insert('key_value')
->fields(array(
'collection',
'name',
'value',
))
->values(array(
'collection' => 'config.entity.key_store.block_content_type',
'name' => 'uuid:966baba6-525e-48fe-b8c5-a5f131b1857f',
'value' => 'a:1:{i:0;s:37:"block_content.type.test_block_content";}',
))
->execute();
$connection->insert('node')
->fields(array(
'nid',
'vid',
'type',
'uuid',
'langcode',
))
->values(array(
'nid' => '1',
'vid' => '1',
'type' => 'article',
'uuid' => '11143847-fe18-4808-a797-8b15966adf4c',
'langcode' => 'en',
))
->values(array(
'nid' => '2',
'vid' => '3',
'type' => 'article',
'uuid' => '336e6941-9340-419e-a763-65d4c11ea031',
'langcode' => 'en',
))
->values(array(
'nid' => '3',
'vid' => '5',
'type' => 'article',
'uuid' => '3eebe337-f977-4a32-94d2-4095947f125d',
'langcode' => 'en',
))
->execute();
$connection->insert('node_field_data')
->fields(array(
'nid',
'vid',
'type',
'langcode',
'status',
'title',
'uid',
'created',
'changed',
'promote',
'sticky',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'nid' => '1',
'vid' => '1',
'type' => 'article',
'langcode' => 'en',
'status' => '1',
'title' => 'draft pending revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '2',
'vid' => '3',
'type' => 'article',
'langcode' => 'en',
'status' => '1',
'title' => 'published default revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '3',
'vid' => '5',
'type' => 'article',
'langcode' => 'en',
'status' => '0',
'title' => 'archived default revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('node_field_revision')
->fields(array(
'nid',
'vid',
'langcode',
'status',
'title',
'uid',
'created',
'changed',
'promote',
'sticky',
'default_langcode',
'revision_translation_affected',
))
->values(array(
'nid' => '1',
'vid' => '1',
'langcode' => 'en',
'status' => '1',
'title' => 'draft pending revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '1',
'vid' => '2',
'langcode' => 'en',
'status' => '0',
'title' => 'draft pending revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '2',
'vid' => '3',
'langcode' => 'en',
'status' => '1',
'title' => 'published default revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '3',
'vid' => '4',
'langcode' => 'en',
'status' => '1',
'title' => 'archived default revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->values(array(
'nid' => '3',
'vid' => '5',
'langcode' => 'en',
'status' => '0',
'title' => 'archived default revision',
'uid' => '0',
'created' => '1517725800',
'changed' => '1517725800',
'promote' => '1',
'sticky' => '0',
'default_langcode' => '1',
'revision_translation_affected' => '1',
))
->execute();
$connection->insert('node_revision')
->fields(array(
'nid',
'vid',
'langcode',
'revision_uid',
'revision_timestamp',
'revision_log',
))
->values(array(
'nid' => '1',
'vid' => '1',
'langcode' => 'en',
'revision_uid' => '0',
'revision_timestamp' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'nid' => '1',
'vid' => '2',
'langcode' => 'en',
'revision_uid' => '0',
'revision_timestamp' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'nid' => '2',
'vid' => '3',
'langcode' => 'en',
'revision_uid' => '0',
'revision_timestamp' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'nid' => '3',
'vid' => '4',
'langcode' => 'en',
'revision_uid' => '0',
'revision_timestamp' => '1517725800',
'revision_log' => NULL,
))
->values(array(
'nid' => '3',
'vid' => '5',
'langcode' => 'en',
'revision_uid' => '0',
'revision_timestamp' => '1517725800',
'revision_log' => NULL,
))
->execute();

View file

@ -0,0 +1,9 @@
name: 'Content moderation test local task'
type: module
description: 'Provides a local task for testing.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:content_moderation
- drupal:node

View file

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

View file

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

View file

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

View file

@ -0,0 +1,407 @@
langcode: en
status: true
dependencies:
config:
- system.menu.main
module:
- content_moderation
- user
id: latest
label: Latest
module: views
description: ''
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view all revisions'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: full
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous: ' Previous'
next: 'Next '
first: '« First'
last: 'Last »'
quantity: 9
style:
type: table
row:
type: fields
fields:
nid:
id: nid
table: node_field_revision
field: nid
relationship: none
group_type: group
admin_label: ''
label: 'Node ID'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
label: 'Revision ID'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: vid
plugin_id: field
title:
id: title
table: node_field_revision
field: title
entity_type: node
entity_field: title
alter:
alter_text: false
make_link: false
absolute: false
trim: false
word_boundary: false
ellipsis: false
strip_tags: false
html: false
hide_empty: false
empty_zero: false
settings:
link_to_entity: false
plugin_id: field
relationship: none
group_type: group
admin_label: ''
label: Title
exclude: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_alter_empty: true
click_sort_column: value
type: string
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
moderation_state:
id: moderation_state
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: 'Moderation state'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
filters:
latest_revision:
id: latest_revision
table: node_revision
field: latest_revision
relationship: none
group_type: group
admin_label: ''
operator: '='
value: ''
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: latest_revision
sorts: { }
title: Latest
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: latest
menu:
type: normal
title: Drafts
description: ''
expanded: false
parent: ''
weight: 0
context: '0'
menu_name: main
cache_metadata:
max-age: 0
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,401 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: test_content_moderation_base_table_test
label: test_content_moderation_base_table_test
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
moderation_state:
id: moderation_state
table: content_moderation_state_field_data
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
moderation_state_1:
id: moderation_state_1
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: content_moderation_state
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
moderation_state_2:
id: moderation_state_2
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state_1
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
filters: { }
sorts:
created:
id: created
table: node_field_data
field: created
order: DESC
entity_type: node
entity_field: created
plugin_id: date
relationship: none
group_type: group
admin_label: ''
exposed: false
expose:
label: ''
granularity: second
vid:
id: vid
table: node_field_data
field: vid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: vid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_data
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
moderation_state_1:
id: moderation_state_1
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state (revision)'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,205 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: test_content_moderation_field_state_test
label: test_content_moderation_field_state_test
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: some
options:
items_per_page: 10
offset: 0
style:
type: default
row:
type: fields
options:
default_field_elements: true
inline: { }
separator: ''
hide_empty: false
fields:
title:
id: title
table: node_field_data
field: title
entity_type: node
entity_field: title
label: ''
alter:
alter_text: false
make_link: false
absolute: false
trim: false
word_boundary: false
ellipsis: false
strip_tags: false
html: false
hide_empty: false
empty_zero: false
settings:
link_to_entity: true
plugin_id: field
relationship: none
group_type: group
admin_label: ''
exclude: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_alter_empty: true
click_sort_column: value
type: string
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
moderation_state:
id: moderation_state
table: node_field_data
field: moderation_state
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: content_moderation_state
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
plugin_id: field
filters: { }
sorts: { }
title: test_content_moderation_field_state_test
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: test-content-moderation-field-state-test
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,315 @@
langcode: en
status: true
dependencies:
module:
- user
id: test_content_moderation_revision_test
label: test_content_moderation_revision_test
module: views
description: ''
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view all revisions'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: vid
plugin_id: field
moderation_state:
id: moderation_state
table: content_moderation_state_field_revision
field: moderation_state
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: target_id
type: string
settings: { }
group_column: target_id
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: moderation_state
plugin_id: field
revision_id:
id: revision_id
table: content_moderation_state_field_revision
field: revision_id
relationship: moderation_state
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: content_moderation_state
entity_field: revision_id
plugin_id: field
filters: { }
sorts:
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: vid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: 'Content moderation state'
required: false
entity_type: node
plugin_id: standard
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,260 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: test_content_moderation_state_filter_base_table
label: test_content_moderation_state_filter_base_table
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: none
options:
offset: 0
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: false
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
filters:
moderation_state:
id: moderation_state
table: node_field_data
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: moderation_state_op
label: 'Default Revision State'
description: ''
use_operator: false
operator: moderation_state_op
identifier: default_revision_state
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
moderation_state_1:
id: moderation_state_1
table: node_field_data
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: 'not empty'
value: { }
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
sorts:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: nid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:workflow_list'
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: filter-test-path
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:workflow_list'

View file

@ -0,0 +1,222 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- node
- user
id: test_content_moderation_state_filter_base_table_filter_on_revision
label: test_content_moderation_state_filter_base_table_filter_on_revision
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: none
options:
offset: 0
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: false
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
filters:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: moderation_state_op
label: 'Moderation state'
description: ''
use_operator: false
operator: moderation_state_op
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
sorts:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: nid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:workflow_list'
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: filter-on-revision-test-path
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:workflow_list'

View file

@ -0,0 +1,167 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- entity_test
id: test_content_moderation_state_filter_entity_test
label: test_content_moderation_state_filter_entity_test
module: views
description: ''
tag: ''
base_table: entity_test_no_bundle
base_field: id
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: none
options: { }
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: none
options:
offset: 0
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
entity_id:
id: entity_id
table: content_revision_tracker
field: entity_id
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
plugin_id: standard
filters:
moderation_state:
id: moderation_state
table: entity_test_no_bundle
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: moderation_state_op
label: 'Moderation state'
description: ''
use_operator: false
operator: moderation_state_op
identifier: moderation_state
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: entity_test_no_bundle
plugin_id: moderation_state_filter
sorts: { }
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- url
tags: { }

View file

@ -0,0 +1,217 @@
langcode: en
status: true
dependencies:
module:
- content_moderation
- user
id: test_content_moderation_state_filter_revision_table
label: test_content_moderation_state_filter_revision_table
module: views
description: ''
tag: ''
base_table: node_field_revision
base_field: vid
core: 8.x
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'view all revisions'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: default
options:
grouping: { }
row_class: ''
default_row_class: true
uses_fields: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
nid:
id: nid
table: node_field_revision
field: nid
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: number_integer
settings:
thousand_separator: ''
prefix_suffix: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: nid
plugin_id: field
filters:
moderation_state:
id: moderation_state
table: node_field_revision
field: moderation_state
relationship: none
group_type: group
admin_label: ''
operator: in
value: { }
group: 1
exposed: true
expose:
operator_id: moderation_state_op
label: 'Moderation state'
description: ''
use_operator: false
operator: moderation_state_op
identifier: moderation_state
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
plugin_id: moderation_state_filter
sorts:
vid:
id: vid
table: node_field_revision
field: vid
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: vid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- url
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,11 @@
name: 'Content moderation test views'
type: module
description: 'Provides default views for views Content moderation tests.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:content_moderation
- drupal:node
- drupal:views
- drupal:entity_test

View file

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

View file

@ -0,0 +1,114 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Test updating the ContentModerationState entity default revisions.
*
* @group Update
* @group legacy
* @see content_moderation_post_update_update_cms_default_revisions
*/
class DefaultContentModerationStateRevisionUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz',
__DIR__ . '/../../fixtures/update/drupal-8.4.0-content_moderation_installed.php',
];
}
/**
* Test updating the default revision.
*/
public function testUpdateDefaultRevision() {
// Include the database fixture required to test updating the default
// revision. This is excluded from ::setDatabaseDumpFiles so that we can
// test the same post_update hook with no test content enabled.
require __DIR__ . '/../../fixtures/update/drupal-8.default-cms-entity-id-2941736.php';
$this->runUpdates();
foreach (['node', 'block_content'] as $entity_type_id) {
$draft_pending_revision = $this->getEntityByLabel($entity_type_id, 'draft pending revision');
$this->assertFalse($draft_pending_revision->isLatestRevision());
$this->assertCompositeEntityMatchesDefaultRevisionId($draft_pending_revision);
$published_default_revision = $this->getEntityByLabel($entity_type_id, 'published default revision');
$this->assertTrue($published_default_revision->isLatestRevision());
$this->assertCompositeEntityMatchesDefaultRevisionId($published_default_revision);
$archived_default_revision = $this->getEntityByLabel($entity_type_id, 'archived default revision');
$this->assertTrue($archived_default_revision->isLatestRevision());
$this->assertCompositeEntityMatchesDefaultRevisionId($archived_default_revision);
}
}
/**
* Test the post_update hook when no entity types are being moderated.
*/
public function testNoEntitiesUnderModeration() {
// If any errors occur during the post_update hook, the test case will fail.
$this->runUpdates();
}
/**
* Assert for the given entity, the default revision ID matches.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to use for the assertion.
*/
protected function assertCompositeEntityMatchesDefaultRevisionId(ContentEntityInterface $entity) {
$entity_type_manager = $this->container->get('entity_type.manager');
$entity_list = $entity_type_manager->getStorage('content_moderation_state')
->loadByProperties([
'content_entity_type_id' => $entity->getEntityTypeId(),
'content_entity_id' => $entity->id(),
]);
$content_moderation_state_entity = array_shift($entity_list);
$this->assertEquals($entity->getLoadedRevisionId(), $content_moderation_state_entity->content_entity_revision_id->value);
// Check that the data table records were updated correctly.
/** @var \Drupal\Core\Database\Connection $database */
$database = $this->container->get('database');
$query = 'SELECT * FROM {content_moderation_state_field_data} WHERE id = :id';
$records = $database->query($query, [':id' => $content_moderation_state_entity->id()])
->fetchAllAssoc('langcode');
foreach ($records as $langcode => $record) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $translation */
$translation = $content_moderation_state_entity->getTranslation($langcode);
foreach ((array) $record as $field_name => $value) {
if ($translation->hasField($field_name)) {
$items = $translation->get($field_name)->getValue();
$this->assertEquals(current($items[0]), $value);
}
}
}
}
/**
* Load an entity by label.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $label
* The label of the entity to load.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The loaded entity.
*/
protected function getEntityByLabel($entity_type_id, $label) {
$entity_type_manager = $this->container->get('entity_type.manager');
$label_field = $entity_type_manager->getDefinition($entity_type_id)->getKey('label');
$entity_list = $entity_type_manager->getStorage($entity_type_id)
->loadByProperties([$label_field => $label]);
return array_shift($entity_list);
}
}

View file

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

View file

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

View file

@ -0,0 +1,109 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Test content_moderation functionality with content_translation.
*
* @group content_moderation
*/
class ModerationContentTranslationTest extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* A user with permission to bypass access content.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'node',
'locale',
'content_translation',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Create an Article content type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article'])->save();
$edit = [
'predefined_langcode' => 'fr',
];
$this->drupalPostForm('admin/config/regional/language/add', $edit, '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, 'Save configuration');
// Adding languages requires a container rebuild in the test running
// environment so that multilingual services are used.
$this->rebuildContainer();
}
/**
* Tests existing translations being edited after enabling content moderation.
*/
public function testModerationWithExistingContent() {
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
];
$this->drupalPostForm('node/add/article', $edit, 'Save');
$this->assertSession()->pageTextContains('Article Published English node has been created.');
$english_node = $this->drupalGetNodeByTitle('Published English node');
// Add a French translation.
$this->drupalGet('node/' . $english_node->id() . '/translations');
$this->clickLink('Add');
$edit = [
'title[0][value]' => 'Published French node',
];
$this->drupalPostForm(NULL, $edit, 'Save (this translation)');
$this->assertSession()->pageTextContains('Article Published French node has been updated.');
// Install content moderation and enable moderation on Article node type.
\Drupal::service('module_installer')->install(['content_moderation']);
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article');
$workflow->save();
$this->drupalLogin($this->rootUser);
// Edit the English node.
$this->drupalGet('node/' . $english_node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$edit = [
'title[0][value]' => 'Published English new node',
];
$this->drupalPostForm(NULL, $edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Article Published English new node has been updated.');
// Edit the French translation.
$this->drupalGet('fr/node/' . $english_node->id() . '/edit');
$this->assertSession()->statusCodeEquals(200);
$edit = [
'title[0][value]' => 'Published French new node',
];
$this->drupalPostForm(NULL, $edit, 'Save (this translation)');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Article Published French new node has been updated.');
}
}

View file

@ -0,0 +1,523 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Url;
/**
* Tests the moderation form, specifically on nodes.
*
* @group content_moderation
*/
class ModerationFormTest extends ModerationStateTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'node',
'content_moderation',
'locale',
'content_translation',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE);
$this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
}
/**
* Tests the moderation form that shows on the latest version page.
*
* The latest version page only shows if there is a pending revision.
*
* @see \Drupal\content_moderation\EntityOperations
* @see \Drupal\Tests\content_moderation\Functional\ModerationStateBlockTest::testCustomBlockModeration
*/
public function testModerationForm() {
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$canonical_path = sprintf('node/%d', $node->id());
$edit_path = sprintf('node/%d/edit', $node->id());
$latest_version_path = sprintf('node/%d/latest', $node->id());
$this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content'));
// The canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The node view page has a moderation form.');
// The latest version page should not show, because there is no pending
// revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Update the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save'));
// The canonical view should have a moderation form, because it is not the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The node view page has a moderation form.');
// Preview the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Preview'));
// The preview view should not have a moderation form.
$preview_url = Url::fromRoute('entity.node.preview', [
'node_preview' => $node->uuid(),
'view_mode_id' => 'full',
]);
$this->assertResponse(200);
$this->assertUrl($preview_url);
$this->assertNoField('edit-new-state', 'The node preview page has no moderation form.');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Publish the draft.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Third version of the content.',
'moderation_state[0][state]' => 'published',
], t('Save'));
// Check widget default value.
$this->drupalGet($edit_path);
$this->assertFieldByName('moderation_state[0][state]', 'published', 'The moderation default value is set correctly.');
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoField('edit-new-state', 'The node view page has no moderation form.');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
// Make a pending revision.
$this->drupalPostForm($edit_path, [
'body[0][value]' => 'Fourth version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save'));
// The published view should not have a moderation form, because it is the
// live revision.
$this->drupalGet($canonical_path);
$this->assertResponse(200);
$this->assertNoField('edit-new-state', 'The node view page has no moderation form.');
// The latest version page should show the moderation form and have "Draft"
// status, because the pending revision is in "Draft".
$this->drupalGet($latest_version_path);
$this->assertResponse(200);
$this->assertField('edit-new-state', 'The latest-version page has a moderation form.');
$this->assertText('Draft', 'Correct status found on the latest-version page.');
// Submit the moderation form to change status to published.
$this->drupalPostForm($latest_version_path, [
'new_state' => 'published',
], t('Apply'));
// The latest version page should not show, because there is no
// pending revision.
$this->drupalGet($latest_version_path);
$this->assertResponse(403);
}
/**
* Test moderation non-bundle entity type.
*/
public function testNonBundleModerationForm() {
$this->drupalLogin($this->rootUser);
$this->workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_mulrevpub', 'entity_test_mulrevpub');
$this->workflow->save();
// Create new moderated content in draft.
$this->drupalPostForm('entity_test_mulrevpub/add', ['moderation_state[0][state]' => 'draft'], t('Save'));
// The latest version page should not show, because there is no pending
// revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Update the draft.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', ['moderation_state[0][state]' => 'draft'], t('Save'));
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet('/entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Publish the draft.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', ['moderation_state[0][state]' => 'published'], t('Save'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should not show, because there is still no
// pending revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
// Make a pending revision.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/edit', ['moderation_state[0][state]' => 'draft'], t('Save'));
// The published view should not have a moderation form, because it is the
// default revision.
$this->drupalGet('entity_test_mulrevpub/manage/1');
$this->assertResponse(200);
$this->assertNoText('Status', 'The node view page has no moderation form.');
// The latest version page should show the moderation form and have "Draft"
// status, because the pending revision is in "Draft".
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(200);
$this->assertText('Moderation state', 'Form text found on the latest-version page.');
$this->assertText('Draft', 'Correct status found on the latest-version page.');
// Submit the moderation form to change status to published.
$this->drupalPostForm('entity_test_mulrevpub/manage/1/latest', [
'new_state' => 'published',
], t('Apply'));
// The latest version page should not show, because there is no
// pending revision.
$this->drupalGet('entity_test_mulrevpub/manage/1/latest');
$this->assertResponse(403);
}
/**
* Tests the revision author is updated when the moderation form is used.
*/
public function testModerationFormSetsRevisionAuthor() {
// Create new moderated content in published.
$node = $this->createNode(['type' => 'moderated_content', 'moderation_state' => 'published']);
// Make a pending revision.
$node->title = $this->randomMachineName();
$node->moderation_state->value = 'draft';
$node->setRevisionCreationTime(12345);
$node->save();
$another_user = $this->drupalCreateUser($this->permissions);
$this->grantUserPermissionToCreateContentOfType($another_user, 'moderated_content');
$this->drupalLogin($another_user);
$this->drupalPostForm(sprintf('node/%d/latest', $node->id()), [
'new_state' => 'published',
], t('Apply'));
$this->drupalGet(sprintf('node/%d/revisions', $node->id()));
$this->assertText('by ' . $another_user->getAccountName());
// Verify the revision creation time has been updated.
$node = $node->load($node->id());
$this->assertGreaterThan(12345, $node->getRevisionCreationTime());
}
/**
* Tests translated and moderated nodes.
*/
public function testContentTranslationNodeForm() {
$this->drupalLogin($this->rootUser);
// 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][moderated_content][translatable]' => TRUE,
'settings[node][moderated_content][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 new moderated content in draft (revision 1).
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save'));
$this->assertTrue($this->xpath('//ul[@class="entity-moderation-form"]'));
$node = $this->drupalGetNodeByTitle('Some moderated content');
$this->assertTrue($node->language(), 'en');
$edit_path = sprintf('node/%d/edit', $node->id());
$translate_path = sprintf('node/%d/translations/add/en/fr', $node->id());
$latest_version_path = sprintf('node/%d/latest', $node->id());
$french = \Drupal::languageManager()->getLanguage('fr');
$this->drupalGet($latest_version_path);
$this->assertSession()->statusCodeEquals('403');
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Add french translation (revision 2).
$this->drupalGet($translate_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Second version of the content.',
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertSession()->statusCodeEquals('403');
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Add french pending revision (revision 3).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Third version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertTrue($this->xpath('//ul[@class="entity-moderation-form"]'));
$this->drupalGet($edit_path);
$this->clickLink('Delete');
$this->assertSession()->buttonExists('Delete');
$this->drupalGet($latest_version_path);
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Publish the french pending revision (revision 4).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Fifth version of the content.',
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Publish the English pending revision (revision 5).
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Sixth version of the content.',
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path);
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Make sure we are allowed to create a pending French revision.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Add an English pending revision (revision 6).
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Seventh version of the content.',
'moderation_state[0][state]' => 'draft',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path);
$this->assertTrue($this->xpath('//ul[@class="entity-moderation-form"]'));
$this->drupalGet($latest_version_path, ['language' => $french]);
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Publish the English pending revision (revision 7)
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'body[0][value]' => 'Eighth version of the content.',
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->drupalGet($latest_version_path);
$this->assertFalse($this->xpath('//ul[@class="entity-moderation-form"]'));
// Make sure we are allowed to create a pending French revision.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Make sure we are allowed to create a pending English revision.
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
// Create new moderated content (revision 1).
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Third moderated content',
'moderation_state[0][state]' => 'published',
], t('Save'));
$node = $this->drupalGetNodeByTitle('Third moderated content');
$this->assertTrue($node->language(), 'en');
$edit_path = sprintf('node/%d/edit', $node->id());
$translate_path = sprintf('node/%d/translations/add/en/fr', $node->id());
// Translate it, without updating data (revision 2).
$this->drupalGet($translate_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'moderation_state[0][state]' => 'draft',
], t('Save (this translation)'));
// Add another draft for the translation (revision 3).
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'moderation_state[0][state]' => 'draft',
], t('Save (this translation)'));
// Updating and publishing the french translation is still possible.
$this->drupalGet($edit_path, ['language' => $french]);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
// Now the french translation is published, an english draft can be added.
$this->drupalGet($edit_path);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
$this->assertSession()->optionExists('moderation_state[0][state]', 'archived');
$this->drupalPostForm(NULL, [
'moderation_state[0][state]' => 'draft',
], t('Save (this translation)'));
}
/**
* Test the moderation_state field when an alternative widget is set.
*/
public function testAlternativeModerationStateWidget() {
$entity_form_display = EntityFormDisplay::load('node.moderated_content.default');
$entity_form_display->setComponent('moderation_state', [
'type' => 'string_textfield',
'region' => 'content',
]);
$entity_form_display->save();
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Test content',
'moderation_state[0][value]' => 'published',
], 'Save');
$this->assertSession()->pageTextContains('Moderated content Test content has been created.');
}
/**
* Tests that workflows and states can not be deleted if they are in use.
*
* @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowHasData
* @covers \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::workflowStateHasData
*/
public function testWorkflowInUse() {
$user = $this->createUser([
'administer workflows',
'create moderated_content content',
'edit own moderated_content content',
'use editorial transition create_new_draft',
'use editorial transition publish',
'use editorial transition archive',
]);
$this->drupalLogin($user);
$paths = [
'archived_state' => 'admin/config/workflow/workflows/manage/editorial/state/archived/delete',
'editorial_workflow' => 'admin/config/workflow/workflows/manage/editorial/delete',
];
$messages = [
'archived_state' => 'This workflow state is in use. You cannot remove this workflow state until you have removed all content using it.',
'editorial_workflow' => 'This workflow is in use. You cannot remove this workflow until you have removed all content using it.',
];
foreach ($paths as $path) {
$this->drupalGet($path);
$this->assertSession()->buttonExists('Delete');
}
// Create new moderated content in draft.
$this->drupalPostForm('node/add/moderated_content', [
'title[0][value]' => 'Some moderated content',
'body[0][value]' => 'First version of the content.',
'moderation_state[0][state]' => 'draft',
], 'Save');
// The archived state is not used yet, so can still be deleted.
$this->drupalGet($paths['archived_state']);
$this->assertSession()->buttonExists('Delete');
// The workflow is being used, so can't be deleted.
$this->drupalGet($paths['editorial_workflow']);
$this->assertSession()->buttonNotExists('Delete');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains($messages['editorial_workflow']);
$node = $this->drupalGetNodeByTitle('Some moderated content');
$this->drupalPostForm('node/' . $node->id() . '/edit', [
'moderation_state[0][state]' => 'published',
], 'Save');
$this->drupalPostForm('node/' . $node->id() . '/edit', [
'moderation_state[0][state]' => 'archived',
], 'Save');
// Now the archived state is being used so it can not be deleted either.
foreach ($paths as $type => $path) {
$this->drupalGet($path);
$this->assertSession()->buttonNotExists('Delete');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains($messages[$type]);
}
}
}

View file

@ -0,0 +1,562 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\NodeInterface;
/**
* 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',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi('Article', 'article', TRUE);
// Add French and Italian languages.
foreach (['fr', 'it'] as $langcode) {
$edit = [
'predefined_langcode' => $langcode,
];
$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();
}
/**
* Tests article translations can be moderated separately.
*/
public function testTranslateModeratedContent() {
// Create a published article in English.
$edit = [
'title[0][value]' => 'Published English node',
'langcode[0][value]' => 'en',
'moderation_state[0][state]' => 'published',
];
$this->drupalPostForm('node/add/article', $edit, t('Save'));
$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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm('node/add/article', $edit, t('Save'));
$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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (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', [
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->assertText(t('Article English node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('English node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual('French node', $french_node->label());
$this->assertEqual($english_node->moderation_state->value, 'published');
$this->assertTrue($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->value, 'draft');
$this->assertFalse($french_node->isPublished());
// Create another article with its translation. This time we will publish
// the translation first.
$edit = [
'title[0][value]' => 'Another node',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm('node/add/article', $edit, t('Save'));
$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',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm(NULL, $edit, t('Save (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', [
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->assertText(t('Article Translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($english_node->moderation_state->value, 'draft');
$this->assertFalse($english_node->isPublished());
// Now check that we can create a new draft of the translation.
$edit = [
'title[0][value]' => 'New draft of translated node',
'moderation_state[0][state]' => 'draft',
];
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.');
// Publish the French article before testing the archive transition.
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($french_node->moderation_state->value, 'published');
$this->assertTrue($french_node->isPublished());
$this->assertEqual($french_node->getTitle(), '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', [
'moderation_state[0][state]' => 'published',
], t('Save (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$this->assertEqual($english_node->moderation_state->value, 'published');
// Archive the node and its translation.
$this->drupalPostForm('node/' . $english_node->id() . '/edit', [
'moderation_state[0][state]' => 'archived',
], t('Save (this translation)'));
$this->assertText(t('Article Another node has been updated.'));
$this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [
'moderation_state[0][state]' => 'archived',
], t('Save (this translation)'));
$this->assertText(t('Article New draft of translated node has been updated.'));
$english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
$french_node = $english_node->getTranslation('fr');
$this->assertEqual($english_node->moderation_state->value, 'archived');
$this->assertFalse($english_node->isPublished());
$this->assertEqual($french_node->moderation_state->value, 'archived');
$this->assertFalse($french_node->isPublished());
}
/**
* Tests that individual translations can be moderated independently.
*/
public function testLanguageIndependentContentModeration() {
// Create a published article in English (revision 1).
$this->drupalGet('node/add/article');
$node = $this->submitNodeForm('Test 1.1 EN', 'published');
$this->assertNotLatestVersionPage($node);
$edit_path = $node->toUrl('edit-form');
$translate_path = $node->toUrl('drupal:content-translation-overview');
// Create a new English draft (revision 2).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.2 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node);
// Add a French translation draft (revision 3).
$this->drupalGet($translate_path);
$this->clickLink(t('Add'));
$this->submitNodeForm('Test 1.3 FR', 'draft');
$fr_node = $this->loadTranslation($node, 'fr');
$this->assertLatestVersionPage($fr_node);
$this->assertModerationForm($node);
// Add an Italian translation draft (revision 4).
$this->drupalGet($translate_path);
$this->clickLink(t('Add'));
$this->submitNodeForm('Test 1.4 IT', 'draft');
$it_node = $this->loadTranslation($node, 'it');
$this->assertLatestVersionPage($it_node);
$this->assertModerationForm($node);
$this->assertModerationForm($fr_node);
// Publish the English draft (revision 5).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.5 EN', 'published', TRUE);
$this->assertNotLatestVersionPage($node);
$this->assertModerationForm($fr_node);
$this->assertModerationForm($it_node);
// Publish the Italian draft (revision 6).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 2);
$this->submitNodeForm('Test 1.6 IT', 'published');
$this->assertNotLatestVersionPage($it_node);
$this->assertNoModerationForm($node);
$this->assertModerationForm($fr_node);
// Publish the French draft (revision 7).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 1);
$this->submitNodeForm('Test 1.7 FR', 'published');
$this->assertNotLatestVersionPage($fr_node);
$this->assertNoModerationForm($node);
$this->assertNoModerationForm($it_node);
// Create an Italian draft (revision 8).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 2);
$this->submitNodeForm('Test 1.8 IT', 'draft');
$this->assertLatestVersionPage($it_node);
$this->assertNoModerationForm($node);
$this->assertNoModerationForm($fr_node);
// Create a French draft (revision 9).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 1);
$this->submitNodeForm('Test 1.9 FR', 'draft');
$this->assertLatestVersionPage($fr_node);
$this->assertNoModerationForm($node);
$this->assertModerationForm($it_node);
// Create an English draft (revision 10).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 1.10 EN', 'draft');
$this->assertLatestVersionPage($node);
$this->assertModerationForm($fr_node);
$this->assertModerationForm($it_node);
// Now start from a draft article in English (revision 1).
$this->drupalGet('node/add/article');
$node2 = $this->submitNodeForm('Test 2.1 EN', 'draft', TRUE);
$this->assertNotLatestVersionPage($node2, TRUE);
$edit_path = $node2->toUrl('edit-form');
$translate_path = $node2->toUrl('drupal:content-translation-overview');
// Add a French translation (revision 2).
$this->drupalGet($translate_path);
$this->clickLink(t('Add'));
$this->submitNodeForm('Test 2.2 FR', 'draft');
$fr_node2 = $this->loadTranslation($node2, 'fr');
$this->assertNotLatestVersionPage($fr_node2, TRUE);
$this->assertModerationForm($node2, FALSE);
// Add an Italian translation (revision 3).
$this->drupalGet($translate_path);
$this->clickLink(t('Add'));
$this->submitNodeForm('Test 2.3 IT', 'draft');
$it_node2 = $this->loadTranslation($node2, 'it');
$this->assertNotLatestVersionPage($it_node2, TRUE);
$this->assertModerationForm($node2, FALSE);
$this->assertModerationForm($fr_node2, FALSE);
// Publish the English draft (revision 4).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 2.4 EN', 'published', TRUE);
$this->assertNotLatestVersionPage($node2);
$this->assertModerationForm($fr_node2, FALSE);
$this->assertModerationForm($it_node2, FALSE);
// Publish the Italian draft (revision 5).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 2);
$this->submitNodeForm('Test 2.5 IT', 'published');
$this->assertNotLatestVersionPage($it_node2);
$this->assertNoModerationForm($node2);
$this->assertModerationForm($fr_node2, FALSE);
// Publish the French draft (revision 6).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 1);
$this->submitNodeForm('Test 2.6 FR', 'published');
$this->assertNotLatestVersionPage($fr_node2);
$this->assertNoModerationForm($node2);
$this->assertNoModerationForm($it_node2);
// Now that all revision translations are published, verify that the
// moderation form is never displayed on revision pages.
/** @var \Drupal\node\NodeStorageInterface $storage */
$storage = $this->container->get('entity_type.manager')->getStorage('node');
foreach (range(11, 16) as $revision_id) {
/** @var \Drupal\node\NodeInterface $revision */
$revision = $storage->loadRevision($revision_id);
foreach ($revision->getTranslationLanguages() as $langcode => $language) {
if ($revision->isRevisionTranslationAffected()) {
$this->drupalGet($revision->toUrl('revision'));
$this->assertFalse($this->hasModerationForm(), 'Moderation form is not displayed correctly for revision ' . $revision_id);
break;
}
}
}
// Create an Italian draft (revision 7).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 2);
$this->submitNodeForm('Test 2.7 IT', 'draft');
$this->assertLatestVersionPage($it_node2);
$this->assertNoModerationForm($node2);
$this->assertNoModerationForm($fr_node2);
// Create a French draft (revision 8).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 1);
$this->submitNodeForm('Test 2.8 FR', 'draft');
$this->assertLatestVersionPage($fr_node2);
$this->assertNoModerationForm($node2);
$this->assertModerationForm($it_node2);
// Create an English draft (revision 9).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 2.9 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node2);
$this->assertModerationForm($fr_node2);
$this->assertModerationForm($it_node2);
// Now publish a draft in another language first and verify that the
// moderation form is not displayed on the English node view page.
$this->drupalGet('node/add/article');
$node3 = $this->submitNodeForm('Test 3.1 EN', 'published');
$this->assertNotLatestVersionPage($node3);
$edit_path = $node3->toUrl('edit-form');
$translate_path = $node3->toUrl('drupal:content-translation-overview');
// Create an English draft (revision 2).
$this->drupalGet($edit_path);
$this->submitNodeForm('Test 3.2 EN', 'draft', TRUE);
$this->assertLatestVersionPage($node3);
// Add a French translation (revision 3).
$this->drupalGet($translate_path);
$this->clickLink(t('Add'));
$this->submitNodeForm('Test 3.3 FR', 'draft');
$fr_node3 = $this->loadTranslation($node3, 'fr');
$this->assertLatestVersionPage($fr_node3);
$this->assertModerationForm($node3);
// Publish the French draft (revision 4).
$this->drupalGet($translate_path);
$this->clickLink(t('Edit'), 1);
$this->submitNodeForm('Test 3.4 FR', 'published');
$this->assertNotLatestVersionPage($fr_node3);
$this->assertModerationForm($node3);
}
/**
* Checks that new translation values are populated properly.
*/
public function testNewTranslationSourceValues() {
// Create a published article in Italian (revision 1).
$this->drupalGet('node/add/article');
$node = $this->submitNodeForm('Test 1.1 IT', 'published', TRUE, 'it');
$this->assertNotLatestVersionPage($node);
// Create a new draft (revision 2).
$this->drupalGet($node->toUrl('edit-form'));
$this->submitNodeForm('Test 1.2 IT', 'draft', TRUE);
$this->assertLatestVersionPage($node);
// Create an English draft (revision 3) and verify that the Italian draft
// values are used as source values.
$url = $node->toUrl('drupal:content-translation-add');
$url->setRouteParameter('source', 'it');
$url->setRouteParameter('target', 'en');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.2 IT');
$this->submitNodeForm('Test 1.3 EN', 'draft');
$this->assertLatestVersionPage($node);
// Create a French draft (without saving) and verify that the Italian draft
// values are used as source values.
$url->setRouteParameter('target', 'fr');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.2 IT');
// Now switch source language and verify that the English draft values are
// used as source values.
$url->setRouteParameter('source', 'en');
$this->drupalGet($url);
$this->assertSession()->pageTextContains('Test 1.3 EN');
}
/**
* Submits the node form at the current URL with the specified values.
*
* @param string $title
* The node title.
* @param string $moderation_state
* The moderation state.
* @param bool $default_translation
* (optional) Whether we are editing the default translation.
* @param string|null $langcode
* (optional) The node language. Defaults to English.
*
* @return \Drupal\node\NodeInterface|null
* A node object if a new one is being created, NULL otherwise.
*/
protected function submitNodeForm($title, $moderation_state, $default_translation = FALSE, $langcode = 'en') {
$is_new = strpos($this->getSession()->getCurrentUrl(), '/node/add/') !== FALSE;
$edit = [
'title[0][value]' => $title,
'moderation_state[0][state]' => $moderation_state,
];
if ($is_new) {
$default_translation = TRUE;
$edit['langcode[0][value]'] = $langcode;
}
$submit = $default_translation ? t('Save') : t('Save (this translation)');
$this->drupalPostForm(NULL, $edit, $submit);
$message = $is_new ? "Article $title has been created." : "Article $title has been updated.";
$this->assertSession()->pageTextContains($message);
return $is_new ? $this->drupalGetNodeByTitle($title) : NULL;
}
/**
* Loads the node translation for the specified language.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param string $langcode
* The translation language code.
*
* @return \Drupal\node\NodeInterface
* The node translation object.
*/
protected function loadTranslation(NodeInterface $node, $langcode) {
/** @var \Drupal\node\NodeStorageInterface $storage */
$storage = $this->container->get('entity_type.manager')->getStorage('node');
// Explicitly invalidate the cache for that node, as the call below is
// statically cached.
$storage->resetCache([$node->id()]);
/** @var \Drupal\node\NodeInterface $node */
$node = $storage->loadRevision($storage->getLatestRevisionId($node->id()));
return $node->getTranslation($langcode);
}
/**
* Asserts that this is the "latest version" page for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
*/
public function assertLatestVersionPage(NodeInterface $node) {
$this->assertEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl());
$this->assertModerationForm($node);
}
/**
* Asserts that this is not the "latest version" page for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param bool $moderation_form
* (optional) Whether the page should contain the moderation form. Defaults
* to FALSE.
*/
public function assertNotLatestVersionPage(NodeInterface $node, $moderation_form = FALSE) {
$this->assertNotEquals($node->toUrl('latest-version')->setAbsolute()->toString(), $this->getSession()->getCurrentUrl());
if ($moderation_form) {
$this->assertModerationForm($node, FALSE);
}
else {
$this->assertNoModerationForm($node);
}
}
/**
* Asserts that the moderation form is displayed for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
* @param bool $latest_tab
* (optional) Whether the node form is expected to be displayed on the
* latest version page or on the node view page. Defaults to the former.
*/
public function assertModerationForm(NodeInterface $node, $latest_tab = TRUE) {
$this->drupalGet($node->toUrl());
$this->assertEquals(!$latest_tab, $this->hasModerationForm());
$this->drupalGet($node->toUrl('latest-version'));
$this->assertEquals($latest_tab, $this->hasModerationForm());
}
/**
* Asserts that the moderation form is not displayed for the specified node.
*
* @param \Drupal\node\NodeInterface $node
* A node object.
*/
public function assertNoModerationForm(NodeInterface $node) {
$this->drupalGet($node->toUrl());
$this->assertFalse($this->hasModerationForm());
$this->drupalGet($node->toUrl('latest-version'));
$this->assertEquals(403, $this->getSession()->getStatusCode());
}
/**
* Checks whether the page contains the moderation form.
*
* @return bool
* TRUE if the moderation form could be find in the page, FALSE otherwise.
*/
public function hasModerationForm() {
return (bool) $this->xpath('//ul[@class="entity-moderation-form"]');
}
}

View file

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

View file

@ -0,0 +1,110 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests the view access control handler for moderation state entities.
*
* @group content_moderation
*/
class ModerationStateAccessTest extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation_test_views',
'content_moderation',
];
/**
* Test the view operation access handler with the view permission.
*/
public function testViewShowsCorrectStates() {
$node_type_id = 'test';
$this->createNodeType('Test', $node_type_id);
$permissions = [
'access content',
'view all revisions',
];
$editor1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($editor1);
$node_1 = Node::create([
'type' => $node_type_id,
'title' => 'Draft node',
'uid' => $editor1->id(),
]);
$node_1->moderation_state->value = 'draft';
$node_1->save();
$node_2 = Node::create([
'type' => $node_type_id,
'title' => 'Published node',
'uid' => $editor1->id(),
]);
$node_2->moderation_state->value = 'published';
$node_2->save();
// Resave the node with a new state.
$node_2->setTitle('Archived node');
$node_2->moderation_state->value = 'archived';
$node_2->save();
// Now show the View, and confirm that the state labels are showing.
$this->drupalGet('/latest');
$page = $this->getSession()->getPage();
$this->assertTrue($page->hasContent('Draft'));
$this->assertTrue($page->hasContent('Archived'));
$this->assertFalse($page->hasContent('Published'));
// Now log in as an admin and test the same thing.
$permissions = [
'access content',
'view all revisions',
];
$admin1 = $this->drupalCreateUser($permissions);
$this->drupalLogin($admin1);
$this->drupalGet('/latest');
$page = $this->getSession()->getPage();
$this->assertEquals(200, $this->getSession()->getStatusCode());
$this->assertTrue($page->hasContent('Draft'));
$this->assertTrue($page->hasContent('Archived'));
$this->assertFalse($page->hasContent('Published'));
}
/**
* Creates a new node type.
*
* @param string $label
* The human-readable label of the type to create.
* @param string $machine_name
* The machine name of the type to create.
*
* @return \Drupal\node\Entity\NodeType
* The node type just created.
*/
protected function createNodeType($label, $machine_name) {
/** @var \Drupal\node\Entity\NodeType $node_type */
$node_type = NodeType::create([
'type' => $machine_name,
'label' => $label,
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', $machine_name);
$workflow->save();
return $node_type;
}
}

View file

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

View file

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

View file

@ -0,0 +1,91 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
/**
* Tests moderation state node type integration.
*
* @group content_moderation
*/
class ModerationStateNodeTypeTest extends ModerationStateTestBase {
/**
* A node type without moderation state disabled.
*
* @covers \Drupal\content_moderation\EntityTypeInfo::formAlter
* @covers \Drupal\content_moderation\Entity\Handler\NodeModerationHandler::enforceRevisionsBundleFormAlter
*/
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');
$this->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save'));
$this->assertText('Not moderated Test has been created.');
}
/**
* Tests enabling moderation on an existing node-type, with content.
*
* @covers \Drupal\content_moderation\EntityTypeInfo::formAlter
* @covers \Drupal\content_moderation\Entity\Handler\NodeModerationHandler::enforceRevisionsBundleFormAlter
*/
public function testEnablingOnExistingContent() {
$editor_permissions = [
'administer workflows',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
'use editorial transition create_new_draft',
];
$publish_permissions = array_merge($editor_permissions, ['use editorial transition publish']);
$editor = $this->drupalCreateUser($editor_permissions);
$editor_with_publish = $this->drupalCreateUser($publish_permissions);
// Create a node type that is not moderated.
$this->drupalLogin($editor);
$this->createContentTypeFromUi('Not moderated', 'not_moderated');
$this->grantUserPermissionToCreateContentOfType($editor, 'not_moderated');
$this->grantUserPermissionToCreateContentOfType($editor_with_publish, 'not_moderated');
// Create content.
$this->drupalGet('node/add/not_moderated');
$this->drupalPostForm(NULL, [
'title[0][value]' => 'Test',
], t('Save'));
$this->assertText('Not moderated Test has been created.');
// Now enable moderation state.
$this->enableModerationThroughUi('not_moderated');
// 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->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionNotExists('moderation_state[0][state]', 'published');
$this->drupalLogin($editor_with_publish);
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertResponse(200);
$this->assertSession()->optionExists('moderation_state[0][state]', 'draft');
$this->assertSession()->optionExists('moderation_state[0][state]', 'published');
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\user\Entity\Role;
/**
* Defines a base class for moderation state tests.
*/
abstract class ModerationStateTestBase extends BrowserTestBase {
use ContentModerationTestTrait;
/**
* Profile to use.
*
* @var string
*/
protected $profile = 'testing';
/**
* Admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* Permissions to grant admin user.
*
* @var array
*/
protected $permissions = [
'administer workflows',
'access administration pages',
'administer content types',
'administer nodes',
'view latest version',
'view any unpublished content',
'access content overview',
'use editorial transition create_new_draft',
'use editorial transition publish',
'use editorial transition archive',
'use editorial transition archived_draft',
'use editorial transition archived_published',
];
/**
* The editorial workflow entity.
*
* @var \Drupal\workflows\Entity\Workflow
*/
protected $workflow;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'content_moderation',
'block',
'block_content',
'node',
'entity_test',
];
/**
* Sets the test up.
*/
protected function setUp() {
parent::setUp();
$this->workflow = $this->createEditorialWorkflow();
$this->adminUser = $this->drupalCreateUser($this->permissions);
$this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']);
$this->drupalPlaceBlock('page_title_block');
$this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']);
}
/**
* Gets the permission machine name for a transition.
*
* @param string $workflow_id
* The workflow ID.
* @param string $transition_id
* The transition ID.
*
* @return string
* The permission machine name for a transition.
*/
protected function getWorkflowTransitionPermission($workflow_id, $transition_id) {
return 'use ' . $workflow_id . ' transition ' . $transition_id;
}
/**
* Creates a content-type from the UI.
*
* @param string $content_type_name
* Content type human name.
* @param string $content_type_id
* Machine name.
* @param bool $moderated
* TRUE if should be moderated.
* @param string $workflow_id
* The workflow to attach to the bundle.
*/
protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, $workflow_id = 'editorial') {
$this->drupalGet('admin/structure/types');
$this->clickLink('Add content type');
// Check that the 'Create new revision' checkbox is checked and disabled.
$this->assertSession()->checkboxChecked('options[revision]');
$this->assertSession()->fieldDisabled('options[revision]');
$edit = [
'name' => $content_type_name,
'type' => $content_type_id,
];
$this->drupalPostForm(NULL, $edit, t('Save content type'));
// Check the content type has been set to create new revisions.
$this->assertTrue(NodeType::load($content_type_id)->isNewRevision());
if ($moderated) {
$this->enableModerationThroughUi($content_type_id, $workflow_id);
}
}
/**
* Enable moderation for a specified content type, using the UI.
*
* @param string $content_type_id
* Machine name.
* @param string $workflow_id
* The workflow to attach to the bundle.
*/
public function enableModerationThroughUi($content_type_id, $workflow_id = 'editorial') {
$this->drupalGet('/admin/config/workflow/workflows');
$this->assertLinkByHref('admin/config/workflow/workflows/manage/' . $workflow_id);
$edit['bundles[' . $content_type_id . ']'] = TRUE;
$this->drupalPostForm('admin/config/workflow/workflows/manage/' . $workflow_id . '/type/node', $edit, t('Save'));
// Ensure the parent environment is up-to-date.
// @see content_moderation_workflow_insert()
\Drupal::service('entity_type.bundle.info')->clearCachedBundles();
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
/** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */
$router_builder = $this->container->get('router.builder');
$router_builder->rebuildIfNeeded();
}
/**
* Grants given user permission to create content of given type.
*
* @param \Drupal\Core\Session\AccountInterface $account
* User to grant permission to.
* @param string $content_type_id
* Content type ID.
*/
protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) {
$role_ids = $account->getRoles(TRUE);
/* @var \Drupal\user\RoleInterface $role */
$role_id = reset($role_ids);
$role = Role::load($role_id);
$role->grantPermission(sprintf('create %s content', $content_type_id));
$role->grantPermission(sprintf('edit any %s content', $content_type_id));
$role->grantPermission(sprintf('delete any %s content', $content_type_id));
$role->save();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,755 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\content_moderation\Entity\ContentModerationState;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Language\LanguageInterface;
use Drupal\entity_test\Entity\EntityTestRev;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\workflows\Entity\Workflow;
/**
* Tests links between a content entity and a content_moderation_state entity.
*
* @group content_moderation
*/
class ContentModerationStateTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'entity_test',
'node',
'block',
'block_content',
'media',
'media_test_source',
'image',
'file',
'field',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'text',
'workflows',
];
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('entity_test_rev');
$this->installEntitySchema('entity_test_no_bundle');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('block_content');
$this->installEntitySchema('media');
$this->installEntitySchema('file');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->installSchema('file', 'file_usage');
$this->installConfig(['field', 'system', 'image', 'file', 'media']);
$this->entityTypeManager = $this->container->get('entity_type.manager');
}
/**
* Tests basic monolingual content moderation through the API.
*
* @dataProvider basicModerationTestCases
*/
public function testBasicModeration($entity_type_id) {
$entity = $this->createEntity($entity_type_id);
if ($entity instanceof EntityPublishedInterface) {
$entity->setUnpublished();
}
$entity->save();
$entity = $this->reloadEntity($entity);
$this->assertEquals('draft', $entity->moderation_state->value);
$entity->moderation_state->value = 'published';
$entity->save();
$entity = $this->reloadEntity($entity);
$this->assertEquals('published', $entity->moderation_state->value);
// Change the state without saving the node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
$content_moderation_state->save();
$entity = $this->reloadEntity($entity, 3);
$this->assertEquals('draft', $entity->moderation_state->value);
if ($entity instanceof EntityPublishedInterface) {
$this->assertFalse($entity->isPublished());
}
// Get the default revision.
$entity = $this->reloadEntity($entity);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue((bool) $entity->isPublished());
}
$this->assertEquals(2, $entity->getRevisionId());
$entity->moderation_state->value = 'published';
$entity->save();
$entity = $this->reloadEntity($entity, 4);
$this->assertEquals('published', $entity->moderation_state->value);
// Get the default revision.
$entity = $this->reloadEntity($entity);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue((bool) $entity->isPublished());
}
$this->assertEquals(4, $entity->getRevisionId());
// Update the node to archived which will then be the default revision.
$entity->moderation_state->value = 'archived';
$entity->save();
// Revert to the previous (published) revision.
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$previous_revision = $entity_storage->loadRevision(4);
$previous_revision->isDefaultRevision(TRUE);
$previous_revision->setNewRevision(TRUE);
$previous_revision->save();
// Get the default revision.
$entity = $this->reloadEntity($entity);
$this->assertEquals('published', $entity->moderation_state->value);
if ($entity instanceof EntityPublishedInterface) {
$this->assertTrue($entity->isPublished());
}
// Set an invalid moderation state.
$this->setExpectedException(EntityStorageException::class);
$entity->moderation_state->value = 'foobar';
$entity->save();
}
/**
* Test cases for basic moderation test.
*/
public function basicModerationTestCases() {
return [
'Nodes' => [
'node',
],
'Block content' => [
'block_content',
],
'Media' => [
'media',
],
'Test entity - revisions, data table, and published interface' => [
'entity_test_mulrevpub',
],
'Entity Test with revisions' => [
'entity_test_rev',
],
'Entity without bundle' => [
'entity_test_no_bundle',
],
];
}
/**
* Tests removal of content moderation state entity.
*
* @dataProvider basicModerationTestCases
*/
public function testContentModerationStateDataRemoval($entity_type_id) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->createEntity($entity_type_id);
$entity->save();
$entity = $this->reloadEntity($entity);
$entity->delete();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertFalse($content_moderation_state);
}
/**
* Tests removal of content moderation state entity revisions.
*
* @dataProvider basicModerationTestCases
*/
public function testContentModerationStateRevisionDataRemoval($entity_type_id) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->createEntity($entity_type_id);
$entity->save();
$revision = clone $entity;
$revision->isDefaultRevision(FALSE);
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($revision);
$this->assertTrue($content_moderation_state);
$entity = $this->reloadEntity($entity);
$entity->setNewRevision(TRUE);
$entity->save();
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$entity_storage->deleteRevision($revision->getRevisionId());
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($revision);
$this->assertFalse($content_moderation_state);
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertTrue($content_moderation_state);
}
/**
* Tests removal of content moderation state pending entity revisions.
*
* @dataProvider basicModerationTestCases
*/
public function testContentModerationStatePendingRevisionDataRemoval($entity_type_id) {
$entity = $this->createEntity($entity_type_id);
$entity->moderation_state = 'published';
$entity->save();
$entity->setNewRevision(TRUE);
$entity->moderation_state = 'draft';
$entity->save();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertTrue($content_moderation_state);
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$entity_storage->deleteRevision($entity->getRevisionId());
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertFalse($content_moderation_state);
}
/**
* Tests removal of content moderation state translations.
*
* @dataProvider basicModerationTestCases
*/
public function testContentModerationStateTranslationDataRemoval($entity_type_id) {
// Test content moderation state translation deletion.
if ($this->entityTypeManager->getDefinition($entity_type_id)->isTranslatable()) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $this->createEntity($entity_type_id);
$langcode = 'it';
ConfigurableLanguage::createFromLangcode($langcode)
->save();
$entity->save();
$translation = $entity->addTranslation($langcode, ['title' => 'Titolo test']);
// Make sure we add values for all of the required fields.
if ($entity_type_id == 'block_content') {
$translation->info = $this->randomString();
}
$translation->save();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertTrue($content_moderation_state->hasTranslation($langcode));
$entity->removeTranslation($langcode);
$entity->save();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertFalse($content_moderation_state->hasTranslation($langcode));
}
}
/**
* Tests basic multilingual content moderation through the API.
*/
public function testMultilingualModeration() {
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$english_node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
// Revision 1 (en).
$english_node
->setUnpublished()
->save();
$this->assertEquals('draft', $english_node->moderation_state->value);
$this->assertFalse($english_node->isPublished());
// Create a French translation.
$french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
$french_node->setUnpublished();
// Revision 2 (fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
$this->assertFalse($french_node->isPublished());
// Move English node to create another draft.
$english_node = $this->reloadEntity($english_node);
$english_node->moderation_state->value = 'draft';
// Revision 3 (en, fr).
$english_node->save();
$english_node = $this->reloadEntity($english_node);
$this->assertEquals('draft', $english_node->moderation_state->value);
// French node should still be in draft.
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
// Publish the French node.
$french_node->moderation_state->value = 'published';
// Revision 4 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($french_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$this->assertEquals('published', $french_node->moderation_state->value);
$this->assertTrue($french_node->isPublished());
$english_node = $french_node->getTranslation('en');
$this->assertEquals('draft', $english_node->moderation_state->value);
// Publish the English node.
$english_node->moderation_state->value = 'published';
// Revision 5 (en, fr).
$english_node->save();
$english_node = $this->reloadEntity($english_node);
$this->assertTrue($english_node->isPublished());
// Move the French node back to draft.
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
$french_node->moderation_state->value = 'draft';
// Revision 6 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node, 6)->getTranslation('fr');
$this->assertFalse($french_node->isPublished());
$this->assertTrue($french_node->getTranslation('en')->isPublished());
// Republish the French node.
$french_node->moderation_state->value = 'published';
// Revision 7 (en, fr).
$french_node->save();
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertTrue($french_node->isPublished());
// Change the EN state without saving the node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
// Revision 8 (en, fr).
$content_moderation_state->save();
$english_node = $this->reloadEntity($french_node, $french_node->getRevisionId() + 1);
$this->assertEquals('draft', $english_node->moderation_state->value);
$french_node = $this->reloadEntity($english_node)->getTranslation('fr');
$this->assertEquals('published', $french_node->moderation_state->value);
// This should unpublish the French node.
$content_moderation_state = ContentModerationState::load(1);
$content_moderation_state = $content_moderation_state->getTranslation('fr');
$content_moderation_state->set('moderation_state', 'draft');
$content_moderation_state->setNewRevision(TRUE);
// Revision 9 (en, fr).
$content_moderation_state->save();
$english_node = $this->reloadEntity($english_node, $english_node->getRevisionId());
$this->assertEquals('draft', $english_node->moderation_state->value);
$french_node = $this->reloadEntity($english_node, '9')->getTranslation('fr');
$this->assertEquals('draft', $french_node->moderation_state->value);
// Switching the moderation state to an unpublished state should update the
// entity.
$this->assertFalse($french_node->isPublished());
// Get the default english node.
$english_node = $this->reloadEntity($english_node);
$this->assertTrue($english_node->isPublished());
$this->assertEquals(7, $english_node->getRevisionId());
}
/**
* Tests moderation when the moderation_state field has a config override.
*/
public function testModerationWithFieldConfigOverride() {
NodeType::create([
'type' => 'test_type',
])->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test_type');
$workflow->save();
$fields = $this->container->get('entity_field.manager')->getFieldDefinitions('node', 'test_type');
$field_config = $fields['moderation_state']->getConfig('test_type');
$field_config->setLabel('Field Override!');
$field_config->save();
$node = Node::create([
'title' => 'Test node',
'type' => 'test_type',
]);
$node->save();
$this->assertFalse($node->isPublished());
$this->assertEquals('draft', $node->moderation_state->value);
$node->moderation_state = 'published';
$node->save();
$this->assertTrue($node->isPublished());
$this->assertEquals('published', $node->moderation_state->value);
}
/**
* Tests that entities with special languages can be moderated.
*
* @dataProvider moderationWithSpecialLanguagesTestCases
*/
public function testModerationWithSpecialLanguages($original_language, $updated_language) {
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->save();
// Create a test entity.
$entity = EntityTestRev::create([
'langcode' => $original_language,
]);
$entity->save();
$this->assertEquals('draft', $entity->moderation_state->value);
$entity->moderation_state->value = 'published';
$entity->langcode = $updated_language;
$entity->save();
$this->assertEquals('published', EntityTestRev::load($entity->id())->moderation_state->value);
}
/**
* Test cases for ::testModerationWithSpecialLanguages().
*/
public function moderationWithSpecialLanguagesTestCases() {
return [
'Not specified to not specified' => [
LanguageInterface::LANGCODE_NOT_SPECIFIED,
LanguageInterface::LANGCODE_NOT_SPECIFIED,
],
'English to not specified' => [
'en',
LanguageInterface::LANGCODE_NOT_SPECIFIED,
],
'Not specified to english' => [
LanguageInterface::LANGCODE_NOT_SPECIFIED,
'en',
],
];
}
/**
* Test changing the language of content without adding a translation.
*/
public function testChangingContentLangcode() {
ConfigurableLanguage::createFromLangcode('fr')->save();
NodeType::create([
'type' => 'test_type',
])->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test_type');
$workflow->save();
$entity = Node::create([
'title' => 'Test node',
'langcode' => 'en',
'type' => 'test_type',
]);
$entity->save();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertCount(1, $entity->getTranslationLanguages());
$this->assertCount(1, $content_moderation_state->getTranslationLanguages());
$this->assertEquals('en', $entity->langcode->value);
$this->assertEquals('en', $content_moderation_state->langcode->value);
$entity->langcode = 'fr';
$entity->save();
$content_moderation_state = ContentModerationState::loadFromModeratedEntity($entity);
$this->assertCount(1, $entity->getTranslationLanguages());
$this->assertCount(1, $content_moderation_state->getTranslationLanguages());
$this->assertEquals('fr', $entity->langcode->value);
$this->assertEquals('fr', $content_moderation_state->langcode->value);
}
/**
* Tests that a non-translatable entity type with a langcode can be moderated.
*/
public function testNonTranslatableEntityTypeModeration() {
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->save();
// Check that the tested entity type is not translatable.
$entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_rev');
$this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.');
// Create a test entity.
$entity = EntityTestRev::create();
$entity->save();
$this->assertEquals('draft', $entity->moderation_state->value);
$entity->moderation_state->value = 'published';
$entity->save();
$this->assertEquals('published', EntityTestRev::load($entity->id())->moderation_state->value);
}
/**
* Tests that a non-translatable entity type without a langcode can be
* moderated.
*/
public function testNonLangcodeEntityTypeModeration() {
// Unset the langcode entity key for 'entity_test_rev'.
$entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_rev');
$keys = $entity_type->getKeys();
unset($keys['langcode']);
$entity_type->set('entity_keys', $keys);
\Drupal::state()->set('entity_test_rev.entity_type', $entity_type);
// Update the entity type in order to remove the 'langcode' field.
\Drupal::entityDefinitionUpdateManager()->applyUpdates();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->save();
// Check that the tested entity type is not translatable and does not have a
// 'langcode' entity key.
$entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_rev');
$this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.');
$this->assertFalse($entity_type->getKey('langcode'), "The test entity type does not have a 'langcode' entity key.");
// Create a test entity.
$entity = EntityTestRev::create();
$entity->save();
$this->assertEquals('draft', $entity->moderation_state->value);
$entity->moderation_state->value = 'published';
$entity->save();
$this->assertEquals('published', EntityTestRev::load($entity->id())->moderation_state->value);
}
/**
* Tests the dependencies of the workflow when using content moderation.
*/
public function testWorkflowDependencies() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
// Test both a config and non-config based bundle and entity type.
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev');
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_no_bundle', 'entity_test_no_bundle');
$workflow->save();
$this->assertEquals([
'module' => [
'content_moderation',
'entity_test',
],
'config' => [
'node.type.example',
],
], $workflow->getDependencies());
$this->assertEquals([
'entity_test_no_bundle',
'entity_test_rev',
'node',
], $workflow->getTypePlugin()->getEntityTypes());
// Delete the node type and ensure it is removed from the workflow.
$node_type->delete();
$workflow = Workflow::load('editorial');
$entity_types = $workflow->getTypePlugin()->getEntityTypes();
$this->assertFalse(in_array('node', $entity_types));
// Uninstall entity test and ensure it's removed from the workflow.
$this->container->get('config.manager')->uninstall('module', 'entity_test');
$workflow = Workflow::load('editorial');
$entity_types = $workflow->getTypePlugin()->getEntityTypes();
$this->assertEquals([], $entity_types);
}
/**
* Test the content moderation workflow dependencies for non-config bundles.
*/
public function testWorkflowNonConfigBundleDependencies() {
// Create a bundle not based on any particular configuration.
entity_test_create_bundle('test_bundle');
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test', 'test_bundle');
$workflow->save();
// Ensure the bundle is correctly added to the workflow.
$this->assertEquals([
'module' => [
'content_moderation',
'entity_test',
],
], $workflow->getDependencies());
$this->assertEquals([
'test_bundle',
], $workflow->getTypePlugin()->getBundlesForEntityType('entity_test'));
// Delete the test bundle to ensure the workflow entity responds
// appropriately.
entity_test_delete_bundle('test_bundle');
$workflow = Workflow::load('editorial');
$this->assertEquals([], $workflow->getTypePlugin()->getBundlesForEntityType('entity_test'));
$this->assertEquals([
'module' => [
'content_moderation',
],
], $workflow->getDependencies());
}
/**
* Test the revision default state of the moderation state entity revisions.
*
* @param string $entity_type_id
* The ID of entity type to be tested.
*
* @dataProvider basicModerationTestCases
*/
public function testRevisionDefaultState($entity_type_id) {
// Check that the revision default state of the moderated entity and the
// content moderation state entity always match.
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($entity_type_id);
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $cms_storage */
$cms_storage = $this->entityTypeManager->getStorage('content_moderation_state');
$entity = $this->createEntity($entity_type_id);
$entity->get('moderation_state')->value = 'published';
$storage->save($entity);
/** @var \Drupal\Core\Entity\ContentEntityInterface $cms_entity */
$cms_entity = $cms_storage->loadUnchanged(1);
$this->assertEquals($entity->getLoadedRevisionId(), $cms_entity->get('content_entity_revision_id')->value);
$entity->get('moderation_state')->value = 'published';
$storage->save($entity);
/** @var \Drupal\Core\Entity\ContentEntityInterface $cms_entity */
$cms_entity = $cms_storage->loadUnchanged(1);
$this->assertEquals($entity->getLoadedRevisionId(), $cms_entity->get('content_entity_revision_id')->value);
$entity->get('moderation_state')->value = 'draft';
$storage->save($entity);
/** @var \Drupal\Core\Entity\ContentEntityInterface $cms_entity */
$cms_entity = $cms_storage->loadUnchanged(1);
$this->assertEquals($entity->getLoadedRevisionId() - 1, $cms_entity->get('content_entity_revision_id')->value);
$entity->get('moderation_state')->value = 'published';
$storage->save($entity);
/** @var \Drupal\Core\Entity\ContentEntityInterface $cms_entity */
$cms_entity = $cms_storage->loadUnchanged(1);
$this->assertEquals($entity->getLoadedRevisionId(), $cms_entity->get('content_entity_revision_id')->value);
}
/**
* Creates an entity.
*
* The entity will have required fields populated and the corresponding bundle
* will be enabled for content moderation.
*
* @param string $entity_type_id
* The entity type ID.
*
* @return \Drupal\Core\Entity\ContentEntityInterface
* The created entity.
*/
protected function createEntity($entity_type_id) {
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
$bundle_id = $entity_type_id;
// Set up a bundle entity type for the specified entity type, if needed.
if ($bundle_entity_type_id = $entity_type->getBundleEntityType()) {
$bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_entity_type_id);
$bundle_entity_storage = $this->entityTypeManager->getStorage($bundle_entity_type_id);
$bundle_id = 'example';
if (!$bundle_entity_storage->load($bundle_id)) {
$bundle_entity = $bundle_entity_storage->create([
$bundle_entity_type->getKey('id') => 'example',
]);
if ($entity_type_id == 'media') {
$bundle_entity->set('source', 'test');
$bundle_entity->save();
$source_field = $bundle_entity->getSource()->createSourceField($bundle_entity);
$source_field->getFieldStorageDefinition()->save();
$source_field->save();
$bundle_entity->set('source_configuration', [
'source_field' => $source_field->getName(),
]);
}
$bundle_entity->save();
}
}
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle($entity_type_id, $bundle_id);
$workflow->save();
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$entity = $entity_storage->create([
$entity_type->getKey('label') => 'Test title',
$entity_type->getKey('bundle') => $bundle_id,
]);
// Make sure we add values for all of the required fields.
if ($entity_type_id == 'block_content') {
$entity->info = $this->randomString();
}
return $entity;
}
/**
* Reloads the entity after clearing the static cache.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to reload.
* @param int|bool $revision_id
* The specific revision ID to load. Defaults FALSE and just loads the
* default revision.
*
* @return \Drupal\Core\Entity\EntityInterface
* The reloaded entity.
*/
protected function reloadEntity(EntityInterface $entity, $revision_id = FALSE) {
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
$storage->resetCache([$entity->id()]);
if ($revision_id) {
return $storage->loadRevision($revision_id);
}
return $storage->load($entity->id());
}
}

View file

@ -0,0 +1,147 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\Core\Config\ConfigImporterException;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests how Content Moderation handles workflow config changes.
*
* @group content_moderation
*/
class ContentModerationWorkflowConfigTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'content_moderation',
'user',
'system',
'text',
'workflows',
];
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* @var \Drupal\workflows\Entity\Workflow
*/
protected $workflow;
/**
* @var \Drupal\Core\Config\Entity\ConfigEntityStorage
*/
protected $workflowStorage;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig(['system', 'content_moderation']);
NodeType::create([
'type' => 'example',
])->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()
->addState('test1', 'Test one')
->addState('test2', 'Test two')
->addState('test3', 'Test three')
->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$this->workflow = $workflow;
$this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync'));
}
/**
* Test deleting a state via config import.
*/
public function testDeletingStateViaConfiguration() {
$config_sync = \Drupal::service('config.storage.sync');
// Alter the workflow data.
$config_data = $this->config('workflows.workflow.editorial')->get();
unset($config_data['type_settings']['states']['test1']);
$config_sync->write('workflows.workflow.editorial', $config_data);
// Alter the data of another entity type.
$config_data = $this->config('node.type.example')->get();
$config_data['description'] = 'A new description';
$config_sync->write('node.type.example', $config_data);
// Alter the values of simple config.
$config_data = $this->config('core.extension')->get();
$config_data['module']['node'] = 1;
$config_sync->write('core.extension', $config_data);
// There are no Nodes with the moderation state test1, so this should run
// with no errors.
$this->configImporter()->reset()->import();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
'moderation_state' => 'test2',
]);
$node->save();
$config_data = $this->config('workflows.workflow.editorial')->get();
unset($config_data['type_settings']['states']['test2']);
unset($config_data['type_settings']['states']['test3']);
\Drupal::service('config.storage.sync')->write('workflows.workflow.editorial', $config_data);
// Now there is a Node with the moderation state test2, this will fail.
try {
$this->configImporter()->reset()->import();
$this->fail('ConfigImporterException not thrown, invalid import was not stopped due to deleted state.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.' . PHP_EOL . 'The moderation state Test two is being used, but is not in the source storage.');
$error_log = $this->configImporter->getErrors();
$expected = ['The moderation state Test two is being used, but is not in the source storage.'];
$this->assertEqual($expected, $error_log);
}
\Drupal::service('config.storage.sync')->delete('workflows.workflow.editorial');
// An error should be thrown when trying to delete an in use workflow.
try {
$this->configImporter()->reset()->import();
$this->fail('ConfigImporterException not thrown, invalid import was not stopped due to deleted workflow.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.' . PHP_EOL . 'The moderation state Test two is being used, but is not in the source storage.' . PHP_EOL . 'The workflow Editorial is being used, and cannot be deleted.');
$error_log = $this->configImporter->getErrors();
$expected = [
'The moderation state Test two is being used, but is not in the source storage.',
'The workflow Editorial is being used, and cannot be deleted.',
];
$this->assertEqual($expected, $error_log);
}
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests the API of the ContentModeration workflow type plugin.
*
* @group content_moderation
*
* @coversDefaultClass \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration
*/
class ContentModerationWorkflowTypeApiTest extends KernelTestBase {
/**
* A workflow for testing.
*
* @var \Drupal\workflows\Entity\Workflow
*/
protected $workflow;
/**
* Modules to install.
*
* @var array
*/
public static $modules = [
'workflows',
'content_moderation',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->workflow = Workflow::create(['id' => 'test', 'type' => 'content_moderation']);
}
/**
* @covers ::getBundlesForEntityType
* @covers ::addEntityTypeAndBundle
* @covers ::removeEntityTypeAndBundle
*/
public function testGetBundlesForEntityType() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The content moderation plugin does not validate the existence of the
// entity type or bundle.
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals(['fake_page'], $workflow_plugin->getBundlesForEntityType('fake_node'));
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_block'));
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
}
/**
* @covers ::appliesToEntityTypeAndBundle
* @covers ::addEntityTypeAndBundle
* @covers ::removeEntityTypeAndBundle
*/
public function testAppliesToEntityTypeAndBundle() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The content moderation plugin does not validate the existence of the
// entity type or bundle.
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertTrue($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_block', 'fake_custom'));
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertFalse($workflow_plugin->appliesToEntityTypeAndBundle('fake_node', 'fake_page'));
}
/**
* @covers ::addEntityTypeAndBundle
*/
public function testAddEntityTypeAndBundle() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// The bundles are intentionally added in reverse alphabetical order.
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_article');
// Add another entity type that comes alphabetically before 'fake_node'.
$workflow_plugin->addEntityTypeAndBundle('fake_block', 'fake_custom');
// The entity type keys and bundle values should be sorted alphabetically.
// The bundle array index should not reflect the order in which they are
// added.
$this->assertSame(
['fake_block' => ['fake_custom'], 'fake_node' => ['fake_article', 'fake_page']],
$workflow_plugin->getConfiguration()['entity_types']
);
}
/**
* @covers ::addEntityTypeAndBundle
* @covers ::removeEntityTypeAndBundle
*/
public function testRemoveEntityTypeAndBundle() {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $workflow_plugin */
$workflow_plugin = $this->workflow->getTypePlugin();
// There should be no bundles for fake_node to start with.
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
// Removing a bundle which is not set on the workflow should not throw an
// error and should still result in none being returned.
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
// Adding a bundle for fake_node should result it in being returned, but
// then removing it will return no bundles for fake_node.
$workflow_plugin->addEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals(['fake_page'], $workflow_plugin->getBundlesForEntityType('fake_node'));
$workflow_plugin->removeEntityTypeAndBundle('fake_node', 'fake_page');
$this->assertEquals([], $workflow_plugin->getBundlesForEntityType('fake_node'));
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* Tests the correct default revision is set.
*
* @group content_moderation
*/
class DefaultRevisionStateTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'entity_test',
'node',
'block_content',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'text',
'workflows',
];
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('entity_test_with_bundle');
$this->installEntitySchema('entity_test_rev');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('block_content');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->entityTypeManager = $this->container->get('entity_type.manager');
}
/**
* Tests a translatable Node.
*/
public function testMultilingual() {
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$this->container->get('content_translation.manager')->setEnabled('node', 'example', TRUE);
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$english_node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
// Revision 1 (en).
$english_node
->setUnpublished()
->save();
$this->assertEquals('draft', $english_node->moderation_state->value);
$this->assertFalse($english_node->isPublished());
$this->assertTrue($english_node->isDefaultRevision());
$this->assertModerationState($english_node->getRevisionId(), $english_node->language()->getId(), 'draft');
// Revision 2 (fr)
$french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
$french_node->moderation_state->value = 'published';
$french_node->save();
$this->assertTrue($french_node->isPublished());
$this->assertTrue($french_node->isDefaultRevision());
$this->assertModerationState($french_node->getRevisionId(), $french_node->language()->getId(), 'published');
// Revision 3 (fr)
$node = Node::load($english_node->id())->getTranslation('fr');
$node->moderation_state->value = 'draft';
$node->save();
$this->assertFalse($node->isPublished());
$this->assertFalse($node->isDefaultRevision());
$this->assertModerationState($node->getRevisionId(), $node->language()->getId(), 'draft');
// Revision 4 (en)
$latest_revision = $this->entityTypeManager->getStorage('node')->loadRevision(3);
$latest_revision->moderation_state->value = 'draft';
$latest_revision->save();
$this->assertFalse($latest_revision->isPublished());
$this->assertFalse($latest_revision->isDefaultRevision());
$this->assertModerationState($latest_revision->getRevisionId(), $latest_revision->language()->getId(), 'draft');
}
/**
* Verifies the expected moderation state revision exists.
*
* @param int $revision_id
* The revision ID of the host entity.
* @param string $langcode
* The language code of the host entity to check.
* @param string $expected_state
* The state the content moderation state revision should be in.
* @param string $expected_workflow
* The workflow the content moderation state revision should be using.
*/
protected function assertModerationState($revision_id, $langcode, $expected_state, $expected_workflow = 'editorial') {
$moderation_state_storage = $this->entityTypeManager->getStorage('content_moderation_state');
$query = $moderation_state_storage->getQuery();
$results = $query->allRevisions()
->condition('content_entity_revision_id', $revision_id)
->condition('langcode', $langcode)
->execute();
$this->assertCount(1, $results);
$moderation_state = $moderation_state_storage
->loadRevision(key($results))
->getTranslation($langcode);
$this->assertEquals($expected_state, $moderation_state->get('moderation_state')->value);
$this->assertEquals($expected_workflow, $moderation_state->get('workflow')->target_id);
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
/**
* @coversDefaultClass \Drupal\content_moderation\EntityOperations
*
* @group content_moderation
*/
class EntityOperationsTest extends KernelTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'content_moderation',
'node',
'user',
'system',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('node');
$this->installSchema('node', 'node_access');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->createNodeType();
}
/**
* Creates a page node type to test with, ensuring that it's moderated.
*/
protected function createNodeType() {
$node_type = NodeType::create([
'type' => 'page',
'label' => 'Page',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page');
$workflow->save();
}
/**
* Verifies that the process of saving pending revisions works as expected.
*/
public function testPendingRevisions() {
// Create a new node in draft.
$page = Node::create([
'type' => 'page',
'title' => 'A',
]);
$page->moderation_state->value = 'draft';
$page->save();
$id = $page->id();
// Verify the entity saved correctly, and that the presence of pending
// revisions doesn't affect the default node load.
/** @var \Drupal\node\Entity\Node $page */
$page = Node::load($id);
$this->assertEquals('A', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertFalse($page->isPublished());
// Moderate the entity to published.
$page->setTitle('B');
$page->moderation_state->value = 'published';
$page->save();
// Verify the entity is now published and public.
$page = Node::load($id);
$this->assertEquals('B', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
// Make a new pending revision in Draft.
$page->setTitle('C');
$page->moderation_state->value = 'draft';
$page->save();
// Verify normal loads return the still-default previous version.
$page = Node::load($id);
$this->assertEquals('B', $page->getTitle());
// Verify we can load the pending revision, even if the mechanism is kind
// of gross. Note: revisionIds() is only available on NodeStorageInterface,
// so this won't work for non-nodes. We'd need to use entity queries. This
// is a core bug that should get fixed.
$storage = \Drupal::entityTypeManager()->getStorage('node');
$revision_ids = $storage->revisionIds($page);
sort($revision_ids);
$latest = end($revision_ids);
$page = $storage->loadRevision($latest);
$this->assertEquals('C', $page->getTitle());
$page->setTitle('D');
$page->moderation_state->value = 'published';
$page->save();
// Verify normal loads return the still-default previous version.
$page = Node::load($id);
$this->assertEquals('D', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
// Now check that we can immediately add a new published revision over it.
$page->setTitle('E');
$page->moderation_state->value = 'published';
$page->save();
$page = Node::load($id);
$this->assertEquals('E', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
}
/**
* Verifies that a newly-created node can go straight to published.
*/
public function testPublishedCreation() {
// Create a new node in draft.
$page = Node::create([
'type' => 'page',
'title' => 'A',
]);
$page->moderation_state->value = 'published';
$page->save();
$id = $page->id();
// Verify the entity saved correctly.
/** @var \Drupal\node\Entity\Node $page */
$page = Node::load($id);
$this->assertEquals('A', $page->getTitle());
$this->assertTrue($page->isDefaultRevision());
$this->assertTrue($page->isPublished());
}
/**
* Verifies that an unpublished state may be made the default revision.
*/
public function testArchive() {
$page = Node::create([
'type' => 'page',
'title' => $this->randomString(),
]);
$page->moderation_state->value = 'published';
$page->save();
$id = $page->id();
// The newly-created page should already be published.
$page = Node::load($id);
$this->assertTrue($page->isPublished());
// When the page is moderated to the archived state, then the latest
// revision should be the default revision, and it should be unpublished.
$page->moderation_state->value = 'archived';
$page->save();
$new_revision_id = $page->getRevisionId();
$storage = \Drupal::entityTypeManager()->getStorage('node');
$new_revision = $storage->loadRevision($new_revision_id);
$this->assertFalse($new_revision->isPublished());
$this->assertTrue($new_revision->isDefaultRevision());
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
/**
* @coversDefaultClass \Drupal\content_moderation\ParamConverter\EntityRevisionConverter
* @group content_moderation
* @group legacy
*/
class EntityRevisionConverterTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'user',
'system',
'content_moderation',
'node',
'workflows',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installSchema('system', 'sequences');
$this->installSchema('node', 'node_access');
}
/**
* @covers ::convert
* @expectedDeprecationMessage The load_pending_revision flag has been deprecated. You should use load_latest_revision instead.
*/
public function testDeprecatedLoadPendingRevisionFlag() {
NodeType::create([
'type' => 'article',
])->save();
$node = Node::create([
'title' => 'test',
'type' => 'article',
]);
$node->save();
$node->isDefaultRevision(FALSE);
$node->setNewRevision(TRUE);
$node->save();
$converted = $this->container->get('paramconverter.latest_revision')->convert($node->id(), [
'load_pending_revision' => TRUE,
'type' => 'entity:node',
], 'node', []);
$this->assertEquals($converted->getLoadedRevisionId(), $node->getLoadedRevisionId());
}
}

View file

@ -0,0 +1,403 @@
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator
* @group content_moderation
*/
class EntityStateChangeValidationTest extends KernelTestBase {
use ContentModerationTestTrait;
use UserCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'content_moderation',
'user',
'system',
'language',
'content_translation',
'workflows',
];
/**
* An admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('content_moderation_state');
$this->installConfig('content_moderation');
$this->installSchema('system', ['sequences']);
$this->adminUser = $this->createUser(array_keys($this->container->get('user.permissions')->getPermissions()));
}
/**
* Test valid transitions.
*
* @covers ::validate
*/
public function testValidTransition() {
$this->setCurrentUser($this->adminUser);
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->moderation_state->value = 'draft';
$node->save();
$node->moderation_state->value = 'published';
$this->assertCount(0, $node->validate());
$node->save();
$this->assertEquals('published', $node->moderation_state->value);
}
/**
* Test invalid transitions.
*
* @covers ::validate
*/
public function testInvalidTransition() {
$this->setCurrentUser($this->adminUser);
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->moderation_state->value = 'draft';
$node->save();
$node->moderation_state->value = 'archived';
$violations = $node->validate();
$this->assertCount(1, $violations);
$this->assertEquals('Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>', $violations->get(0)->getMessage());
}
/**
* Test validation with an invalid state.
*/
public function testInvalidState() {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->moderation_state->value = 'invalid_state';
$violations = $node->validate();
$this->assertCount(1, $violations);
$this->assertEquals('State <em class="placeholder">invalid_state</em> does not exist on <em class="placeholder">Editorial</em> workflow', $violations->get(0)->getMessage());
}
/**
* Test validation with content that has no initial state or an invalid state.
*/
public function testInvalidStateWithoutExisting() {
$this->setCurrentUser($this->adminUser);
// Create content without moderation enabled for the content type.
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->save();
// Enable moderation to test validation on existing content, with no
// explicit state.
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addState('deleted_state', 'Deleted state');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
// Validate the invalid state.
$node->moderation_state->value = 'invalid_state';
$violations = $node->validate();
$this->assertCount(1, $violations);
// Assign the node to a state we're going to delete.
$node->moderation_state->value = 'deleted_state';
$node->save();
// Delete the state so $node->original contains an invalid state when
// validating.
$workflow->getTypePlugin()->deleteState('deleted_state');
$workflow->save();
// When there is an invalid state, the content will revert to "draft". This
// will allow a draft to draft transition.
$node->moderation_state->value = 'draft';
$violations = $node->validate();
$this->assertCount(0, $violations);
// This will disallow a draft to archived transition.
$node->moderation_state->value = 'archived';
$violations = $node->validate();
$this->assertCount(1, $violations);
}
/**
* Test state transition validation with multiple languages.
*/
public function testInvalidStateMultilingual() {
$this->setCurrentUser($this->adminUser);
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::create([
'type' => 'example',
'title' => 'English Published Node',
'langcode' => 'en',
'moderation_state' => 'published',
]);
$node->save();
$node_fr = $node->addTranslation('fr', $node->toArray());
$node_fr->setTitle('French Published Node');
$node_fr->save();
$this->assertEquals('published', $node_fr->moderation_state->value);
// Create a pending revision of the original node.
$node->moderation_state = 'draft';
$node->setNewRevision(TRUE);
$node->isDefaultRevision(FALSE);
$node->save();
// For the pending english revision, there should be a violation from draft
// to archived.
$node->moderation_state = 'archived';
$violations = $node->validate();
$this->assertCount(1, $violations);
$this->assertEquals('Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>', $violations->get(0)->getMessage());
// From the default french published revision, there should be none.
$node_fr = Node::load($node->id())->getTranslation('fr');
$this->assertEquals('published', $node_fr->moderation_state->value);
$node_fr->moderation_state = 'archived';
$violations = $node_fr->validate();
$this->assertCount(0, $violations);
// From the latest french revision, there should also be no violation.
$node_fr = Node::load($node->id())->getTranslation('fr');
$this->assertEquals('published', $node_fr->moderation_state->value);
$node_fr->moderation_state = 'archived';
$violations = $node_fr->validate();
$this->assertCount(0, $violations);
}
/**
* Tests that content without prior moderation information can be moderated.
*/
public function testExistingContentWithNoModeration() {
$this->setCurrentUser($this->adminUser);
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
/** @var \Drupal\node\NodeInterface $node */
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
]);
$node->save();
$nid = $node->id();
// Enable moderation for our node type.
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$node = Node::load($nid);
// Having no previous state should not break validation.
$violations = $node->validate();
$this->assertCount(0, $violations);
// Having no previous state should not break saving the node.
$node->setTitle('New');
$node->save();
}
/**
* Tests that content without prior moderation information can be translated.
*/
public function testExistingMultilingualContentWithNoModeration() {
$this->setCurrentUser($this->adminUser);
// Enable French.
ConfigurableLanguage::createFromLangcode('fr')->save();
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
/** @var \Drupal\node\NodeInterface $node */
$node = Node::create([
'type' => 'example',
'title' => 'Test title',
'langcode' => 'en',
]);
$node->save();
$nid = $node->id();
$node = Node::load($nid);
// Creating a translation shouldn't break, even though there's no previous
// moderated revision for the new language.
$node_fr = $node->addTranslation('fr');
$node_fr->setTitle('Francais');
$node_fr->save();
// Enable moderation for our node type.
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
// Reload the French version of the node.
$node = Node::load($nid);
$node_fr = $node->getTranslation('fr');
/** @var \Drupal\node\NodeInterface $node_fr */
$node_fr->setTitle('Nouveau');
$node_fr->save();
}
/**
* @dataProvider transitionAccessValidationTestCases
*/
public function testTransitionAccessValidation($permissions, $target_state, $messages) {
$node_type = NodeType::create([
'type' => 'example',
]);
$node_type->save();
$workflow = $this->createEditorialWorkflow();
$workflow->getTypePlugin()->addState('foo', 'Foo');
$workflow->getTypePlugin()->addTransition('draft_to_foo', 'Draft to foo', ['draft'], 'foo');
$workflow->getTypePlugin()->addTransition('foo_to_foo', 'Foo to foo', ['foo'], 'foo');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
$workflow->save();
$this->setCurrentUser($this->createUser($permissions));
$node = Node::create([
'type' => 'example',
'title' => 'Test content',
'moderation_state' => $target_state,
]);
$this->assertTrue($node->isNew());
$violations = $node->validate();
$this->assertCount(count($messages), $violations);
foreach ($messages as $i => $message) {
$this->assertEquals($message, $violations->get($i)->getMessage());
}
}
/**
* Test cases for ::testTransitionAccessValidation.
*/
public function transitionAccessValidationTestCases() {
return [
'Invalid transition, no permissions validated' => [
[],
'archived',
['Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>'],
],
'Valid transition, missing permission' => [
[],
'published',
['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Published</em>'],
],
'Valid transition, granted published permission' => [
['use editorial transition publish'],
'published',
[],
],
'Valid transition, granted draft permission' => [
['use editorial transition create_new_draft'],
'draft',
[],
],
'Valid transition, incorrect permission granted' => [
['use editorial transition create_new_draft'],
'published',
['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Published</em>'],
],
// Test with an additional state and set of transitions, since the
// "published" transition can start from either "draft" or "published", it
// does not capture bugs that fail to correctly distinguish the initial
// workflow state from the set state of a new entity.
'Valid transition, granted foo permission' => [
['use editorial transition draft_to_foo'],
'foo',
[],
],
'Valid transition, incorrect foo permission granted' => [
['use editorial transition foo_to_foo'],
'foo',
['You do not have access to transition from <em class="placeholder">Draft</em> to <em class="placeholder">Foo</em>'],
],
];
}
}

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