diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index 10ee48c..0d1ccec 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -306,6 +306,10 @@ Contact module - Jibran Ijaz 'jibran' https://www.drupal.org/u/jibran - Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost +Content Moderation module +- Tim Millwood 'timmillwood' https://www.drupal.org/u/timmillwood +- Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan + Content Translation module - Francesco Placella 'plach' https://www.drupal.org/u/plach diff --git a/core/composer.json b/core/composer.json index 3109649..5211f65 100644 --- a/core/composer.json +++ b/core/composer.json @@ -62,6 +62,7 @@ "drupal/config": "self.version", "drupal/config_translation": "self.version", "drupal/contact": "self.version", + "drupal/content_moderation": "self.version", "drupal/content_translation": "self.version", "drupal/contextual": "self.version", "drupal/core-annotation": "self.version", diff --git a/core/modules/content_moderation/config/install/content_moderation.state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml new file mode 100644 index 0000000..0279481 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: { } +id: archived +label: Archived +published: false +default_revision: true +weight: -8 diff --git a/core/modules/content_moderation/config/install/content_moderation.state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml new file mode 100644 index 0000000..c7eb64c --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: { } +id: draft +label: Draft +published: false +default_revision: false +weight: -10 diff --git a/core/modules/content_moderation/config/install/content_moderation.state.published.yml b/core/modules/content_moderation/config/install/content_moderation.state.published.yml new file mode 100644 index 0000000..8467e86 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state.published.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: { } +id: published +label: Published +published: true +default_revision: true +weight: -9 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml new file mode 100644 index 0000000..8fbf9c3 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.archived + - content_moderation.state.draft +id: archived_draft +label: 'Un-archive to Draft' +stateFrom: archived +stateTo: draft +weight: -5 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml new file mode 100644 index 0000000..4be7600 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.archived + - content_moderation.state.published +id: archived_published +label: 'Un-archive' +stateFrom: archived +stateTo: published +weight: -4 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml new file mode 100644 index 0000000..0ba0f34 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.draft +id: draft_draft +label: 'Create New Draft' +stateFrom: draft +stateTo: draft +weight: -10 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml new file mode 100644 index 0000000..cf95d3d --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.draft + - content_moderation.state.published +id: draft_published +label: 'Publish' +stateFrom: draft +stateTo: published +weight: -9 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml new file mode 100644 index 0000000..f3a866a --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.archived + - content_moderation.state.published +id: published_archived +label: 'Archive' +stateFrom: published +stateTo: archived +weight: -6 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml new file mode 100644 index 0000000..bd25a31 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.draft + - content_moderation.state.published +id: published_draft +label: 'Create New Draft' +stateFrom: published +stateTo: draft +weight: -8 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml new file mode 100644 index 0000000..3c09a85 --- /dev/null +++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + config: + - content_moderation.state.published +id: published_published +label: 'Publish' +stateFrom: published +stateTo: published +weight: -7 diff --git a/core/modules/content_moderation/config/schema/content_moderation.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.schema.yml new file mode 100644 index 0000000..7f9e8fd --- /dev/null +++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml @@ -0,0 +1,79 @@ +content_moderation.state.*: + type: config_entity + label: 'Moderation state config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + published: + type: boolean + label: 'Is published' + default_revision: + type: boolean + label: 'Is default revision' + weight: + type: integer + label: 'Weight' + +content_moderation.state_transition.*: + type: config_entity + label: 'Moderation state transition config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + stateFrom: + type: string + label: 'From state' + stateTo: + type: string + label: 'To state' + weight: + type: integer + label: 'Weight' + +node.type.*.third_party.content_moderation: + type: mapping + label: 'Enable moderation states for this node type' + mapping: + enabled: + type: boolean + label: 'Moderation states enabled' + allowed_moderation_states: + type: sequence + sequence: + type: string + label: 'Moderation state' + default_moderation_state: + type: string + label: 'Moderation state for new content' + +block_content.type.*.third_party.content_moderation: + type: mapping + label: 'Enable moderation states for this block content type' + mapping: + enabled: + type: boolean + label: 'Moderation states enabled' + allowed_moderation_states: + type: sequence + sequence: + type: string + label: 'Moderation state' + default_moderation_state: + type: string + label: 'Moderation state for new block content' + +views.filter.latest_revision: + type: views_filter + label: 'Latest revision' + mapping: + value: + type: string + label: 'Value' diff --git a/core/modules/content_moderation/content_moderation.info.yml b/core/modules/content_moderation/content_moderation.info.yml new file mode 100644 index 0000000..6d92b64 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.info.yml @@ -0,0 +1,7 @@ +name: 'Content Moderation' +type: module +description: 'Provides moderation states for content' +version: VERSION +core: 8.x +package: Core (Experimental) +configure: content_moderation.overview diff --git a/core/modules/content_moderation/content_moderation.libraries.yml b/core/modules/content_moderation/content_moderation.libraries.yml new file mode 100644 index 0000000..6caaaf3 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.libraries.yml @@ -0,0 +1,5 @@ +entity-moderation-form: + version: VERSION + css: + layout: + css/entity-moderation-form.css: {} diff --git a/core/modules/content_moderation/content_moderation.links.action.yml b/core/modules/content_moderation/content_moderation.links.action.yml new file mode 100644 index 0000000..9de5061 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.action.yml @@ -0,0 +1,11 @@ +entity.moderation_state.add_form: + route_name: 'entity.moderation_state.add_form' + title: 'Add Moderation state' + appears_on: + - entity.moderation_state.collection + +entity.moderation_state_transition.add_form: + route_name: 'entity.moderation_state_transition.add_form' + title: 'Add Moderation state transition' + appears_on: + - entity.moderation_state_transition.collection diff --git a/core/modules/content_moderation/content_moderation.links.menu.yml b/core/modules/content_moderation/content_moderation.links.menu.yml new file mode 100644 index 0000000..0fcb3eb --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.menu.yml @@ -0,0 +1,21 @@ +# Moderation state menu items definition +content_moderation.overview: + title: 'Content moderation' + route_name: content_moderation.overview + description: 'Configure states and transitions for entities.' + parent: system.admin_config_workflow + +entity.moderation_state.collection: + title: 'Moderation states' + route_name: entity.moderation_state.collection + description: 'Administer moderation states.' + parent: content_moderation.overview + weight: 10 + +# Moderation state transition menu items definition +entity.moderation_state_transition.collection: + title: 'Moderation state transitions' + route_name: entity.moderation_state_transition.collection + description: 'Administer moderation states transitions.' + parent: content_moderation.overview + weight: 20 diff --git a/core/modules/content_moderation/content_moderation.links.task.yml b/core/modules/content_moderation/content_moderation.links.task.yml new file mode 100644 index 0000000..d715219 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.links.task.yml @@ -0,0 +1,3 @@ +moderation_state.entities: + deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks' + weight: 100 diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module new file mode 100644 index 0000000..3a7582d --- /dev/null +++ b/core/modules/content_moderation/content_moderation.module @@ -0,0 +1,249 @@ + draft without new + * revision) - i.e. unpublish + */ + +use Drupal\content_moderation\EntityOperations; +use Drupal\content_moderation\EntityTypeInfo; +use Drupal\content_moderation\ContentPreprocess; +use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode; +use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode; +use Drupal\content_moderation\Plugin\Menu\EditTab; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\node\NodeInterface; +use Drupal\node\Plugin\Action\PublishNode; +use Drupal\node\Plugin\Action\UnpublishNode; + +/** + * 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 .= '

' . t('About') . '

'; + $output .= '

' . t('The Content Moderation module provides basic moderation for content. For more information, see the online documentation for the Content Moderation module.', array(':content_moderation' => 'https://www.drupal.org/documentation/modules/workbench_moderation/')) . '

'; + return $output; + } +} + +/** + * Creates an EntityTypeInfo object to respond to entity hooks. + * + * @return \Drupal\content_moderation\EntityTypeInfo + */ +function _content_moderation_create_entity_type_info() { + return new EntityTypeInfo( + \Drupal::service('string_translation'), + \Drupal::service('content_moderation.moderation_information'), + \Drupal::service('entity_type.manager') + ); +} + +/** + * Implements hook_entity_base_field_info(). + */ +function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) { + return _content_moderation_create_entity_type_info()->entityBaseFieldInfo($entity_type); +} + +/** + * Implements hook_module_implements_alter(). + */ +function content_moderation_module_implements_alter(&$implementations, $hook) { + if ($hook === 'entity_view_alter') { + // Move the content_moderation implementation to the end of the list. + $group = $implementations['content_moderation']; + unset($implementations['content_moderation']); + $implementations['content_moderation'] = $group; + } +} + +/** + * Implements hook_entity_view_alter(). + */ +function content_moderation_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + $moderation_information = \Drupal::service('content_moderation.moderation_information'); + if ($moderation_information->isModeratableEntity($entity) && !$moderation_information->isLatestRevision($entity)) { + // Hide quickedit, because its super confusing for the user to not edit the + // live revision. + unset($build['#attributes']['data-quickedit-entity-id']); + } +} + +/** + * Implements hook_entity_type_alter(). + */ +function content_moderation_entity_type_alter(array &$entity_types) { + _content_moderation_create_entity_type_info()->entityTypeAlter($entity_types); +} + +/** + * Implements hook_entity_operation(). + */ +function content_moderation_entity_operation(EntityInterface $entity) { + _content_moderation_create_entity_type_info()->entityOperation($entity); +} + +/** + * Sets required flag based on enabled state. + */ +function content_moderation_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { + _content_moderation_create_entity_type_info()->entityBundleFieldInfoAlter($fields, $entity_type, $bundle); +} + +/** + * Creates an EntityOperations object to respond to entity operation hooks. + * + * @return \Drupal\content_moderation\EntityOperations + */ +function _content_moderation_create_entity_operations() { + return new EntityOperations( + \Drupal::service('content_moderation.moderation_information'), + \Drupal::service('entity_type.manager'), + \Drupal::service('form_builder'), + \Drupal::service('content_moderation.revision_tracker') + ); +} + +/** + * Implements hook_entity_presave(). + */ +function content_moderation_entity_presave(EntityInterface $entity) { + return _content_moderation_create_entity_operations()->entityPresave($entity); +} + +/** + * Implements hook_entity_insert(). + */ +function content_moderation_entity_insert(EntityInterface $entity) { + return _content_moderation_create_entity_operations()->entityInsert($entity); +} + +/** + * Implements hook_entity_update(). + */ +function content_moderation_entity_update(EntityInterface $entity) { + return _content_moderation_create_entity_operations()->entityUpdate($entity); +} + +/** + * Implements hook_local_tasks_alter(). + */ +function content_moderation_local_tasks_alter(&$local_tasks) { + $content_entity_type_ids = array_keys(array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) { + return $entity_type->isRevisionable(); + })); + + foreach ($content_entity_type_ids as $content_entity_type_id) { + if (isset($local_tasks["entity.$content_entity_type_id.edit_form"])) { + $local_tasks["entity.$content_entity_type_id.edit_form"]['class'] = EditTab::class; + $local_tasks["entity.$content_entity_type_id.edit_form"]['entity_type_id'] = $content_entity_type_id; + } + } +} + +/** + * Implements hook_form_alter(). + */ +function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) { + _content_moderation_create_entity_type_info()->bundleFormAlter($form, $form_state, $form_id); +} + +/** + * Implements hook_preprocess_HOOK(). + * + * Many default node templates rely on $page to determine whether to output the + * node title as part of the node content. + */ +function content_moderation_preprocess_node(&$variables) { + $content_process = new ContentPreprocess(\Drupal::routeMatch()); + $content_process->preprocessNode($variables); +} + +/** + * Implements hook_entity_extra_field_info(). + */ +function content_moderation_entity_extra_field_info() { + return _content_moderation_create_entity_type_info()->entityExtraFieldInfo(); +} + +/** + * Implements hook_entity_view(). + */ +function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + _content_moderation_create_entity_operations()->entityView($build, $entity, $display, $view_mode); +} + +/** + * Implements hook_node_access(). + * + * Nodes in particular should be viewable if unpublished and the user has + * the appropriate permission. This permission is therefore effectively + * mandatory for any user that wants to moderate things. + */ +function content_moderation_node_access(NodeInterface $node, $operation, AccountInterface $account) { + /** @var \Drupal\content_moderation\ModerationInformationInterface $modinfo */ + $moderation_info = Drupal::service('content_moderation.moderation_information'); + + $access_result = NULL; + if ($operation === 'view') { + $access_result = (!$node->isPublished()) + ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content') + : AccessResult::neutral(); + + $access_result->addCacheableDependency($node); + } + elseif ($operation === 'update' && $moderation_info->isModeratableEntity($node) && $node->moderation_state && $node->moderation_state->target_id) { + /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */ + $transition_validation = \Drupal::service('content_moderation.state_transition_validation'); + + $valid_transition_targets = $transition_validation->getValidTransitionTargets($node, $account); + $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden(); + + $access_result->addCacheableDependency($node); + $access_result->addCacheableDependency($account); + foreach ($valid_transition_targets as $valid_transition_target) { + $access_result->addCacheableDependency($valid_transition_target); + } + } + + return $access_result; +} + +/** + * Implements hook_theme(). + */ +function content_moderation_theme() { + return ['entity_moderation_form' => ['render element' => 'form']]; +} + +/** + * Implements hook_action_info_alter(). + */ +function content_moderation_action_info_alter(&$definitions) { + + // The publish/unpublish actions are not valid on moderated entities. So swap + // their implementations out for alternates that will become a no-op on a + // moderated node. If another module has already swapped out those classes, + // though, we'll be polite and do nothing. + if (isset($definitions['node_publish_action']['class']) && $definitions['node_publish_action']['class'] == PublishNode::class) { + $definitions['node_publish_action']['class'] = ModerationOptOutPublishNode::class; + } + if (isset($definitions['node_unpublish_action']['class']) && $definitions['node_unpublish_action']['class'] == UnpublishNode::class) { + $definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class; + } +} diff --git a/core/modules/content_moderation/content_moderation.permissions.yml b/core/modules/content_moderation/content_moderation.permissions.yml new file mode 100644 index 0000000..293a77d --- /dev/null +++ b/core/modules/content_moderation/content_moderation.permissions.yml @@ -0,0 +1,24 @@ +view any unpublished content: + title: 'View any unpublished content' + description: 'This permission is necessary for any users that may moderate content.' + +'view moderation states': + title: 'View moderation states' + description: 'View moderation states.' + +'administer moderation states': + title: 'Administer moderation states' + description: 'Create and edit moderation states.' + 'restrict access': TRUE + +'administer moderation state transitions': + title: 'Administer content moderation state transitions' + description: 'Create and edit content moderation state transitions.' + 'restrict access': TRUE + +view latest version: + title: 'View the latest version' + description: 'View the latest version of an entity. (Also requires "View any unpublished content" permission)' + +permission_callbacks: + - \Drupal\content_moderation\Permissions::transitionPermissions diff --git a/core/modules/content_moderation/content_moderation.routing.yml b/core/modules/content_moderation/content_moderation.routing.yml new file mode 100644 index 0000000..cc7a5ff --- /dev/null +++ b/core/modules/content_moderation/content_moderation.routing.yml @@ -0,0 +1,73 @@ +content_moderation.overview: + path: '/admin/config/workflow/moderation' + defaults: + _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage' + _title: 'Content moderation' + requirements: + _permission: 'access administration pages' + +# ModerationState routing definition +entity.moderation_state.collection: + path: '/admin/config/workflow/moderation/states' + defaults: + _entity_list: 'moderation_state' + _title: 'Moderation states' + requirements: + _permission: 'administer moderation states' + +entity.moderation_state.add_form: + path: '/admin/config/workflow/moderation/states/add' + defaults: + _entity_form: 'moderation_state.add' + _title: 'Add Moderation state' + requirements: + _permission: 'administer moderation states' + +entity.moderation_state.edit_form: + path: '/admin/config/workflow/moderation/states/{moderation_state}' + defaults: + _entity_form: 'moderation_state.edit' + _title: 'Edit Moderation state' + requirements: + _permission: 'administer moderation states' + +entity.moderation_state.delete_form: + path: '/admin/config/workflow/moderation/states/{moderation_state}/delete' + defaults: + _entity_form: 'moderation_state.delete' + _title: 'Delete Moderation state' + requirements: + _permission: 'administer moderation states' + +# ModerationStateTransition routing definition +entity.moderation_state_transition.collection: + path: '/admin/config/workflow/moderation/transitions' + defaults: + _entity_list: 'moderation_state_transition' + _title: 'Moderation state transitions' + requirements: + _permission: 'administer moderation state transitions' + +entity.moderation_state_transition.add_form: + path: '/admin/config/workflow/moderation/transitions/add' + defaults: + _entity_form: 'moderation_state_transition.add' + _title: 'Add Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' + +entity.moderation_state_transition.edit_form: + path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}' + defaults: + _entity_form: 'moderation_state_transition.edit' + _title: 'Edit Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' + +entity.moderation_state_transition.delete_form: + path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete' + defaults: + _entity_form: 'moderation_state_transition.delete' + _title: 'Delete Moderation state transition' + requirements: + _permission: 'administer moderation state transitions' diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml new file mode 100644 index 0000000..c0c9413 --- /dev/null +++ b/core/modules/content_moderation/content_moderation.services.yml @@ -0,0 +1,23 @@ +services: + paramconverter.latest_revision: + class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter + arguments: ['@entity.manager', '@content_moderation.moderation_information'] + tags: + - { name: paramconverter, priority: 5 } + arguments: ['@entity.manager'] + content_moderation.state_transition_validation: + class: \Drupal\content_moderation\StateTransitionValidation + arguments: ['@entity_type.manager', '@entity.query'] + content_moderation.moderation_information: + class: Drupal\content_moderation\ModerationInformation + arguments: ['@entity_type.manager', '@current_user'] + access_check.latest_revision: + class: Drupal\content_moderation\Access\LatestRevisionCheck + arguments: ['@content_moderation.moderation_information'] + tags: + - { name: access_check, applies_to: _content_moderation_latest_version } + content_moderation.revision_tracker: + class: Drupal\content_moderation\RevisionTracker + arguments: ['@database'] + tags: + - { name: backend_overridable } diff --git a/core/modules/content_moderation/content_moderation.views.inc b/core/modules/content_moderation/content_moderation.views.inc new file mode 100644 index 0000000..faabc6a --- /dev/null +++ b/core/modules/content_moderation/content_moderation.views.inc @@ -0,0 +1,37 @@ +getViewsData(); +} + +/** + * Implements hook_views_data_alter(). + */ +function content_moderation_views_data_alter(array &$data) { + _content_moderation_views_data_object()->alterViewsData($data); +} + +/** + * Creates a ViewsData object to respond to views hooks. + * + * @return \Drupal\content_moderation\ViewsData + * The content moderation ViewsData object. + */ +function _content_moderation_views_data_object() { + return new ViewsData( + \Drupal::service('entity_type.manager'), + \Drupal::service('content_moderation.moderation_information') + ); +} diff --git a/core/modules/content_moderation/css/entity-moderation-form.css b/core/modules/content_moderation/css/entity-moderation-form.css new file mode 100644 index 0000000..ec09407 --- /dev/null +++ b/core/modules/content_moderation/css/entity-moderation-form.css @@ -0,0 +1,16 @@ +ul.entity-moderation-form { + list-style: none; + display: -webkit-flex; /* Safari */ + display: flex; + -webkit-flex-wrap: wrap; /* Safari */ + flex-wrap: wrap; + -webkit-justify-content: space-around; /* Safari */ + justify-content: space-around; + -webkit-align-items: flex-end; /* Safari */ + align-items: flex-end; + border-bottom: 1px solid gray; +} + +ul.entity-moderation-form input[type=submit] { + margin-bottom: 1.2em; +} diff --git a/core/modules/content_moderation/src/Access/LatestRevisionCheck.php b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php new file mode 100644 index 0000000..528d195 --- /dev/null +++ b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php @@ -0,0 +1,85 @@ +moderationInfo = $moderation_information; + } + + /** + * Checks that there is a forward revision available. + * + * This checker assumes the presence of an '_entity_access' requirement key + * in the same form as used by EntityAccessCheck. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The parametrized route. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + * + * @see \Drupal\Core\Entity\EntityAccessCheck + */ + public function access(Route $route, RouteMatchInterface $route_match) { + // This tab should not show up unless there's a reason to show it. + $entity = $this->loadEntity($route, $route_match); + return $this->moderationInfo->hasForwardRevision($entity) + ? AccessResult::allowed()->addCacheableDependency($entity) + : AccessResult::forbidden()->addCacheableDependency($entity); + } + + /** + * Returns the default revision of the entity this route is for. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The parametrized route. + * + * @return \Drupal\Core\Entity\ContentEntityInterface + * returns the Entity in question. + * + * @throws \Exception + * A generic exception is thrown if the entity couldn't be loaded. This + * almost always implies a developer error, so it should get turned into + * an HTTP 500. + */ + protected function loadEntity(Route $route, RouteMatchInterface $route_match) { + $entity_type = $route->getOption('_content_moderation_entity_type'); + + if ($entity = $route_match->getParameter($entity_type)) { + if ($entity instanceof EntityInterface) { + return $entity; + } + } + throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName())); + } + +} diff --git a/core/modules/content_moderation/src/ContentModerationStateInterface.php b/core/modules/content_moderation/src/ContentModerationStateInterface.php new file mode 100644 index 0000000..5b7ee2e --- /dev/null +++ b/core/modules/content_moderation/src/ContentModerationStateInterface.php @@ -0,0 +1,16 @@ +routeMatch = $route_match; + } + + /** + * Wrapper for hook_preprocess_HOOK(). + * + * @param array $variables + * Theme variables to preprocess. + */ + public function preprocessNode(array &$variables) { + // Set the 'page' template variable when the node is being displayed on the + // "Latest version" tab provided by content_moderation. + $variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']); + } + + /** + * Checks whether a route is the "Latest version" tab of a node. + * + * @param \Drupal\node\Entity\Node $node + * A node. + * + * @return bool + * True if the current route is the latest version tab of the given node. + */ + public function isLatestVersionPage(Node $node) { + return $this->routeMatch->getRouteName() == 'entity.node.latest_version' + && ($pageNode = $this->routeMatch->getParameter('node')) + && $pageNode->id() == $node->id(); + } + +} diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php new file mode 100644 index 0000000..bdbba21 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php @@ -0,0 +1,179 @@ +setLabel(t('User')) + ->setDescription(t('The username of the entity creator.')) + ->setSetting('target_type', 'user') + ->setDefaultValueCallback('Drupal\content_moderation\Entity\ContentModerationState::getCurrentUserId') + ->setTranslatable(TRUE) + ->setRevisionable(TRUE); + + $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Moderation state')) + ->setDescription(t('The moderation state of the referenced content.')) + ->setSetting('target_type', 'moderation_state') + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->addConstraint('ModerationState', []); + + $fields['content_entity_type_id'] = BaseFieldDefinition::create('string') + ->setLabel(t('Content entity type ID')) + ->setDescription(t('The ID of the content entity type this moderation state is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + $fields['content_entity_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Content entity ID')) + ->setDescription(t('The ID of the content entity this moderation state is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + // @todo Add constraint that enforces unique content_entity_type_id / content_entity_id + // @todo Index on content_entity_type_id, content_entity_id and content_entity_revision_id + + $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Content entity revision ID')) + ->setDescription(t('The revision ID of the content entity this moderation state is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function getOwner() { + return $this->get('uid')->entity; + } + + /** + * {@inheritdoc} + */ + public function getOwnerId() { + return $this->getEntityKey('uid'); + } + + /** + * {@inheritdoc} + */ + public function setOwnerId($uid) { + $this->set('uid', $uid); + return $this; + } + + /** + * {@inheritdoc} + */ + public function setOwner(UserInterface $account) { + $this->set('uid', $account->id()); + return $this; + } + + /** + * Creates or updates an entity's moderation state whilst saving that entity. + * + * @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state + * The content moderation entity content entity to create or save. + * + * @internal + * This method should only be called as a result of saving the related + * content entity. + */ + public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) { + $content_moderation_state->realSave(); + } + + /** + * Default value callback for the 'uid' base field definition. + * + * @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions() + * + * @return array + * An array of default values. + */ + public static function getCurrentUserId() { + return array(\Drupal::currentUser()->id()); + } + + /** + * {@inheritdoc} + */ + public function save() { + $related_entity = \Drupal::entityTypeManager() + ->getStorage($this->content_entity_type_id->value) + ->loadRevision($this->content_entity_revision_id->value); + if ($related_entity instanceof TranslatableInterface) { + $related_entity = $related_entity->getTranslation($this->activeLangcode); + } + $related_entity->moderation_state->target_id = $this->moderation_state->target_id; + return $related_entity->save(); + } + + /** + * Saves an entity permanently. + * + * When saving existing entities, the entity is assumed to be complete, + * partial updates of entities are not supported. + * + * @return int + * Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * In case of failures an exception is thrown. + */ + protected function realSave() { + return parent::save(); + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php new file mode 100644 index 0000000..b88b415 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php @@ -0,0 +1,30 @@ +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.'); + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php new file mode 100644 index 0000000..f2835e4 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php @@ -0,0 +1,76 @@ +setNewRevision(TRUE); + $entity->isDefaultRevision($default_revision); + } + + /** + * {@inheritdoc} + */ + public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle) { + // The Revisions portion of Entity API is not uniformly applied or + // consistent. Until that's fixed in core, we'll make a best-attempt to + // apply it to the common entity patterns so as to avoid every entity type + // needing to implement this method, although some will still need to do so + // for now. This is the API that should be universal, but isn't yet. + // @see \Drupal\node\Entity\NodeType + if (method_exists($bundle, 'setNewRevision')) { + $bundle->setNewRevision(TRUE); + } + // This is the raw property used by NodeType, and likely others. + elseif ($bundle->get('new_revision') !== NULL) { + $bundle->set('new_revision', TRUE); + } + // This is the raw property used by BlockContentType, and maybe others. + elseif ($bundle->get('revision') !== NULL) { + $bundle->set('revision', TRUE); + } + + $bundle->save(); + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + } + +} diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php new file mode 100644 index 0000000..e897cf4 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php @@ -0,0 +1,73 @@ +shouldModerate($entity, $published_state)) { + parent::onPresave($entity, $default_revision, $published_state); + // Only nodes have a concept of published. + /** @var \Drupal\node\NodeInterface $entity */ + $entity->setPublished($published_state); + } + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + $form['revision']['#disabled'] = TRUE; + $form['revision']['#default_value'] = TRUE; + $form['revision']['#description'] = $this->t('Revisions are required.'); + } + + /** + * {@inheritdoc} + */ + public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + /* @var \Drupal\node\Entity\NodeType $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { + // Force the revision checkbox on. + $form['workflow']['options']['#default_value']['revision'] = 'revision'; + $form['workflow']['options']['revision']['#disabled'] = TRUE; + } + } + + /** + * Check if an entity's default revision and/or state needs adjusting. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to check. + * @param bool $published_state + * Whether the state being transitioned to is a published state or not. + * + * @return bool + * TRUE when either the default revision or the state needs to be updated. + */ + protected function shouldModerate(ContentEntityInterface $entity, $published_state) { + // @todo clarify the first condition. + // First condition is needed so you can add a translation. + // Second condition checks to see if the published status has changed. + return $entity->isDefaultTranslation() || $entity->isPublished() !== $published_state; + } + +} diff --git a/core/modules/content_moderation/src/Entity/ModerationState.php b/core/modules/content_moderation/src/Entity/ModerationState.php new file mode 100644 index 0000000..4d1158a --- /dev/null +++ b/core/modules/content_moderation/src/Entity/ModerationState.php @@ -0,0 +1,96 @@ +published; + } + + /** + * {@inheritdoc} + */ + public function isDefaultRevisionState() { + return $this->published || $this->default_revision; + } + +} diff --git a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php new file mode 100644 index 0000000..99dbf93 --- /dev/null +++ b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php @@ -0,0 +1,110 @@ +stateFrom) { + $this->addDependency('config', ModerationState::load($this->stateFrom)->getConfigDependencyName()); + } + if ($this->stateTo) { + $this->addDependency('config', ModerationState::load($this->stateTo)->getConfigDependencyName()); + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function getFromState() { + return $this->stateFrom; + } + + /** + * {@inheritdoc} + */ + public function getToState() { + return $this->stateTo; + } + + /** + * {@inheritdoc} + */ + public function getWeight() { + return $this->weight; + } + +} diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php new file mode 100644 index 0000000..aee7991 --- /dev/null +++ b/core/modules/content_moderation/src/EntityOperations.php @@ -0,0 +1,280 @@ +moderationInfo = $moderation_info; + $this->entityTypeManager = $entity_type_manager; + $this->formBuilder = $form_builder; + $this->tracker = $tracker; + } + + /** + * Determines the default moderation state on load for an entity. + * + * This method is only applicable when an entity is loaded that has + * no moderation state on it, but should. In those cases, failing to set + * one may result in NULL references elsewhere when other code tries to check + * the moderation state of the entity. + * + * The amount of indirection here makes performance a concern, but + * given how Entity API works I don't know how else to do it. + * This reliably gets us *A* valid state. However, that state may be + * not the ideal one. Suggestions on how to better select the default + * state here are welcome. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity for which we want a default state. + * + * @return string + * The default state for the given entity. + */ + protected function getDefaultLoadStateId(ContentEntityInterface $entity) { + return $this->moderationInfo + ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()) + ->getThirdPartySetting('content_moderation', 'default_moderation_state'); + } + + /** + * Acts on an entity and set published status based on the moderation state. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being saved. + */ + public function entityPresave(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + if ($entity->moderation_state->target_id) { + $moderation_state = ModerationState::load($entity->moderation_state->target_id); + $published_state = $moderation_state->isPublishedState(); + + // This entity is default if it is new, the default revision, or the + // default revision is not published. + $update_default_revision = $entity->isNew() + || $moderation_state->isDefaultRevisionState() + || !$this->isDefaultRevisionPublished($entity); + + // Fire per-entity-type logic for handling the save process. + $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state); + } + } + + /** + * Hook bridge. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that was just saved. + * + * @see hook_entity_insert() + */ + public function entityInsert(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + $this->updateOrCreateFromEntity($entity); + $this->setLatestRevision($entity); + } + + /** + * Hook bridge. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that was just saved. + * + * @see hook_entity_update() + */ + public function entityUpdate(EntityInterface $entity) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + $this->updateOrCreateFromEntity($entity); + $this->setLatestRevision($entity); + } + + /** + * Creates or updates the moderation state of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to update or create a moderation state for. + */ + protected function updateOrCreateFromEntity(EntityInterface $entity) { + $moderation_state = $entity->moderation_state->target_id; + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + if (!$moderation_state) { + $moderation_state = $this->moderationInfo + ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()) + ->getThirdPartySetting('content_moderation', 'default_moderation_state'); + } + + // @todo what if $entity->moderation_state->target_id is null at this point? + $entity_type_id = $entity->getEntityTypeId(); + $entity_id = $entity->id(); + $entity_revision_id = $entity->getRevisionId(); + $entity_langcode = $entity->language()->getId(); + + // @todo maybe just try and get it from the computed field? + $entities = $this->entityTypeManager + ->getStorage('content_moderation_state') + ->loadByProperties([ + 'content_entity_type_id' => $entity_type_id, + 'content_entity_id' => $entity_id, + ]); + + /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */ + $content_moderation_state = reset($entities); + if (!($content_moderation_state instanceof ContentModerationStateInterface)) { + $content_moderation_state = ContentModerationState::create([ + 'content_entity_type_id' => $entity_type_id, + 'content_entity_id' => $entity_id, + ]); + } + else { + // Create a new revision. + $content_moderation_state->setNewRevision(TRUE); + } + + // Sync translations. + if (!$content_moderation_state->hasTranslation($entity_langcode)) { + $content_moderation_state->addTranslation($entity_langcode); + } + if ($content_moderation_state->language()->getId() !== $entity_langcode) { + $content_moderation_state = $content_moderation_state->getTranslation($entity_langcode); + } + + // Create the ContentModerationState entity for the inserted entity. + $content_moderation_state->set('content_entity_revision_id', $entity_revision_id); + $content_moderation_state->set('moderation_state', $moderation_state); + ContentModerationState::updateOrCreateFromEntity($content_moderation_state); + } + + /** + * Set the latest revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The content entity to create content_moderation_state entity for. + */ + protected function setLatestRevision(EntityInterface $entity) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $this->tracker->setLatestRevision( + $entity->getEntityTypeId(), + $entity->id(), + $entity->language()->getId(), + $entity->getRevisionId() + ); + } + + /** + * Act on entities being assembled before rendering. + * + * This is a hook bridge. + * + * @see hook_entity_view() + * @see EntityFieldManagerInterface::getExtraFields() + */ + public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + if (!$this->moderationInfo->isModeratableEntity($entity)) { + return; + } + if (!$this->moderationInfo->isLatestRevision($entity)) { + return; + } + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + if ($entity->isDefaultRevision()) { + return; + } + + $component = $display->getComponent('content_moderation_control'); + if ($component) { + $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity); + $build['content_moderation_control']['#weight'] = $component['weight']; + } + } + + /** + * Check if the default revision for the given entity is published. + * + * The default revision is the same as the entity retrieved by "default" from + * the storage handler. If the entity is translated, use the default revision + * of the same language as the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being saved. + * + * @return bool + * TRUE if the default revision is published. FALSE otherwise. + */ + protected function isDefaultRevisionPublished(EntityInterface $entity) { + $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + $default_revision = $storage->load($entity->id()); + + // Ensure we are comparing the same translation as the current entity. + if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) { + // If there is no translation, then there is no default revision and is + // therefore not published. + if (!$default_revision->hasTranslation($entity->language()->getId())) { + return FALSE; + } + + $default_revision = $default_revision->getTranslation($entity->language()->getId()); + } + + return $default_revision && $default_revision->moderation_state->entity->isPublishedState(); + } + +} diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php new file mode 100644 index 0000000..95a1be4 --- /dev/null +++ b/core/modules/content_moderation/src/EntityTypeInfo.php @@ -0,0 +1,363 @@ + NodeModerationHandler::class, + 'block_content' => BlockContentModerationHandler::class, + ]; + + /** + * EntityTypeInfo constructor. + * + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. for form alters. + * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information + * The moderation information service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * Entity type manager. + */ + public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager) { + $this->stringTranslation = $translation; + $this->moderationInfo = $moderation_information; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Adds Moderation configuration to appropriate entity types. + * + * This is an alter hook bridge. + * + * @param EntityTypeInterface[] $entity_types + * The master entity type list to alter. + * + * @see hook_entity_type_alter() + */ + public function entityTypeAlter(array &$entity_types) { + foreach ($this->moderationInfo->selectRevisionableEntityTypes($entity_types) as $type_name => $type) { + $entity_types[$type_name] = $this->addModerationToEntityType($type); + $entity_types[$type->get('bundle_of')] = $this->addModerationToEntity($entity_types[$type->get('bundle_of')]); + } + } + + /** + * Modifies an entity definition to include moderation support. + * + * This primarily just means an extra handler. A Generic one is provided, + * but individual entity types can provide their own as appropriate. + * + * @param \Drupal\Core\Entity\ContentEntityTypeInterface $type + * The content entity definition to modify. + * + * @return \Drupal\Core\Entity\ContentEntityTypeInterface + * The modified content entity definition. + */ + protected function addModerationToEntity(ContentEntityTypeInterface $type) { + if (!$type->hasHandlerClass('moderation')) { + $handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class; + $type->setHandlerClass('moderation', $handler_class); + } + + if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) { + $type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest'); + } + + // @todo Core forgot to add a direct way to manipulate route_provider, so + // we have to do it the sloppy way for now. + $providers = $type->getRouteProviderClasses() ?: []; + if (empty($providers['moderation'])) { + $providers['moderation'] = EntityModerationRouteProvider::class; + $type->setHandlerClass('route_provider', $providers); + } + + return $type; + } + + /** + * Configures moderation configuration support on a entity type definition. + * + * That "configuration support" includes a configuration form, a hypermedia + * link, and a route provider to tie it all together. There's also a + * moderation handler for per-entity-type variation. + * + * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type + * The config entity definition to modify. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface + * The modified config entity definition. + */ + protected function addModerationToEntityType(ConfigEntityTypeInterface $type) { + if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) { + $type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation'); + } + + if (!$type->getFormClass('moderation')) { + $type->setFormClass('moderation', BundleModerationConfigurationForm::class); + } + + // @todo Core forgot to add a direct way to manipulate route_provider, so + // we have to do it the sloppy way for now. + $providers = $type->getRouteProviderClasses() ?: []; + if (empty($providers['moderation'])) { + $providers['moderation'] = EntityTypeModerationRouteProvider::class; + $type->setHandlerClass('route_provider', $providers); + } + + return $type; + } + + /** + * Adds an operation on bundles that should have a Moderation form. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity on which to define an operation. + * + * @return array + * An array of operation definitions. + * + * @see hook_entity_operation() + */ + public function entityOperation(EntityInterface $entity) { + $operations = []; + $type = $entity->getEntityType(); + + if ($this->moderationInfo->isBundleForModeratableEntity($entity)) { + $operations['manage-moderation'] = [ + 'title' => t('Manage moderation'), + 'weight' => 27, + 'url' => Url::fromRoute("entity.{$type->id()}.moderation", [$entity->getEntityTypeId() => $entity->id()]), + ]; + } + + return $operations; + } + + /** + * Gets the "extra fields" for a bundle. + * + * This is a hook bridge. + * + * @see hook_entity_extra_field_info() + * + * @return array + * A nested array of 'pseudo-field' elements. Each list is nested within the + * following keys: entity type, bundle name, context (either 'form' or + * 'display'). The keys are the name of the elements as appearing in the + * renderable array (either the entity form or the displayed entity). The + * value is an associative array: + * - label: The human readable name of the element. Make sure you sanitize + * this appropriately. + * - description: A short description of the element contents. + * - weight: The default weight of the element. + * - visible: (optional) The default visibility of the element. Defaults to + * TRUE. + * - edit: (optional) String containing markup (normally a link) used as the + * element's 'edit' operation in the administration interface. Only for + * 'form' context. + * - delete: (optional) String containing markup (normally a link) used as + * the element's 'delete' operation in the administration interface. Only + * for 'form' context. + */ + public function entityExtraFieldInfo() { + $return = []; + foreach ($this->getModeratedBundles() as $bundle) { + $return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [ + 'label' => $this->t('Moderation control'), + 'description' => $this->t("Status listing and form for the entity's moderation state."), + 'weight' => -20, + 'visible' => TRUE, + ]; + } + + return $return; + } + + /** + * Returns an iterable list of entity names and bundle names under moderation. + * + * That is, this method returns a list of bundles that have Content + * Moderation enabled on them. + * + * @return \Generator + * A generator, yielding a 2 element associative array: + * - entity: The machine name of an entity type, such as "node" or + * "block_content". + * - bundle: The machine name of a bundle, such as "page" or "article". + */ + protected function getModeratedBundles() { + $revisionable_types = $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions()); + /** @var ConfigEntityTypeInterface $type */ + foreach ($revisionable_types as $type_name => $type) { + $result = $this->entityTypeManager + ->getStorage($type_name) + ->getQuery() + ->condition('third_party_settings.content_moderation.enabled', TRUE) + ->execute(); + + foreach ($result as $bundle_name) { + yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name]; + } + } + } + + /** + * Adds base field info to an entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * Entity type for adding base fields to. + * + * @return \Drupal\Core\Field\BaseFieldDefinition[] + * New fields added by moderation state. + */ + public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { + if (!$this->moderationInfo->isModeratableEntityType($entity_type)) { + return []; + } + + $fields = []; + $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Moderation state')) + ->setDescription(t('The moderation state of this piece of content.')) + ->setComputed(TRUE) + ->setClass(ModerationStateFieldItemList::class) + ->setSetting('target_type', 'moderation_state') + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'hidden', + 'weight' => -5, + ]) + // @todo write a custom widget/selection handler plugin instead of + // manual filtering? + ->setDisplayOptions('form', [ + 'type' => 'moderation_state_default', + 'weight' => 5, + 'settings' => [], + ]) + ->addConstraint('ModerationState', []) + ->setDisplayConfigurable('form', FALSE) + ->setDisplayConfigurable('view', FALSE) + ->setTranslatable(TRUE); + + return $fields; + } + + /** + * Adds the ModerationState constraint to bundles that are moderatable. + * + * @param \Drupal\Core\Field\FieldDefinitionInterface[] $fields + * The array of bundle field definitions. + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * @param string $bundle + * The bundle. + * + * @see hook_entity_bundle_field_info_alter(); + */ + public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle) { + if ($this->moderationInfo->isModeratableBundle($entity_type, $bundle) && !empty($fields['moderation_state'])) { + $fields['moderation_state']->addConstraint('ModerationState', []); + } + } + + /** + * Alters bundle forms to enforce revision handling. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param string $form_id + * The form id. + * + * @see hook_form_alter() + */ + public function bundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) { + if ($this->moderationInfo->isRevisionableBundleForm($form_state->getFormObject())) { + /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id); + } + elseif ($this->moderationInfo->isModeratedEntityForm($form_state->getFormObject())) { + /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->enforceRevisionsEntityFormAlter($form, $form_state, $form_id); + + // Submit handler to redirect to the latest version, if available. + $form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect']; + } + } + + /** + * Redirect content entity edit forms on save, if there is a forward revision. + * + * When saving their changes, editors should see those changes displayed on + * the next page. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) { + /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $form_state->getFormObject()->getEntity(); + + $moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information'); + if ($moderation_info->hasForwardRevision($entity) && $entity->hasLinkTemplate('latest-version')) { + $entity_type_id = $entity->getEntityTypeId(); + $form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]); + } + } + +} diff --git a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php new file mode 100644 index 0000000..5498ff8 --- /dev/null +++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php @@ -0,0 +1,196 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('entity_type.manager')); + } + + /** + * {@inheritdoc} + * + * We need to blank out the base form ID so that poorly written form alters + * that use the base form ID to target both add and edit forms don't pick + * up our form. This should be fixed in core. + */ + public function getBaseFormId() { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + $form['enable_moderation_state'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable moderation states.'), + '#description' => $this->t('Content of this type must transition through moderation states in order to be published.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE), + ]; + + // Add a special message when moderation is being disabled. + if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { + $form['enable_moderation_state_note'] = [ + '#type' => 'item', + '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'), + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => FALSE], + ], + ], + ]; + } + + $states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple(); + $label = function(ModerationState $state) { + return $state->label(); + }; + + $options_published = array_map($label, array_filter($states, function(ModerationState $state) { + return $state->isPublishedState(); + })); + + $options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) { + return !$state->isPublishedState(); + })); + + $form['allowed_moderation_states_unpublished'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed moderation states (Unpublished)'), + '#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)), + '#options' => $options_unpublished, + '#required' => TRUE, + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + + $form['allowed_moderation_states_published'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed moderation states (Published)'), + '#description' => $this->t('The allowed published moderation states this content-type can be assigned.'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)), + '#options' => $options_published, + '#required' => TRUE, + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + + // This is screwy, but the key of the array needs to be a user-facing string + // so we have to fully render the translatable string to a real string, or + // else PHP chokes on an object used as an array key. + $options = [ + $this->t('Unpublished')->render() => $options_unpublished, + $this->t('Published')->render() => $options_published, + ]; + + $form['default_moderation_state'] = [ + '#type' => 'select', + '#title' => $this->t('Default moderation state'), + '#options' => $options, + '#description' => $this->t('Select the moderation state for new content'), + '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'), + '#states' => [ + 'visible' => [ + ':input[name=enable_moderation_state]' => ['checked' => TRUE], + ], + ], + ]; + $form['#entity_builders'][] = [$this, 'formBuilderCallback']; + + return parent::form($form, $form_state); + } + + /** + * Form builder callback. + * + * @todo This should be folded into the form method. + * + * @param string $entity_type_id + * The entity type identifier. + * @param \Drupal\Core\Entity\EntityInterface $bundle + * The bundle entity updated with the submitted values. + * @param array $form + * The complete form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function formBuilderCallback($entity_type_id, EntityInterface $bundle, &$form, FormStateInterface $form_state) { + // @todo write a test for this. + if ($bundle instanceof ThirdPartySettingsInterface) { + $bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state')); + $bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')))); + $bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state')); + } + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + if ($form_state->getValue('enable_moderation_state')) { + $allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))); + + if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) { + $form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.')); + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // If moderation is enabled, revisions MUST be enabled as well. Otherwise we + // can't have forward revisions. + if ($form_state->getValue('enable_moderation_state')) { + /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */ + $bundle = $form_state->getFormObject()->getEntity(); + + $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle); + } + + parent::submitForm($form, $form_state); + + drupal_set_message($this->t('Your settings have been saved.')); + } + +} diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php new file mode 100644 index 0000000..39baec0 --- /dev/null +++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php @@ -0,0 +1,161 @@ +moderationInfo = $moderation_info; + $this->validation = $validation; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('content_moderation.moderation_information'), + $container->get('content_moderation.state_transition_validation'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'content_moderation_entity_moderation_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) { + /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ + $current_state = $entity->moderation_state->entity; + + $transitions = $this->validation->getValidTransitions($entity, $this->currentUser()); + + // Exclude self-transitions. + $transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) { + return $transition->getToState() != $current_state->id(); + }); + + $target_states = []; + /** @var ModerationStateTransition $transition */ + foreach ($transitions as $transition) { + $target_states[$transition->getToState()] = $transition->label(); + } + + if (!count($target_states)) { + return $form; + } + + if ($current_state) { + $form['current'] = [ + '#type' => 'item', + '#title' => $this->t('Status'), + '#markup' => $current_state->label(), + ]; + } + + // Persist the entity so we can access it in the submit handler. + $form_state->set('entity', $entity); + + $form['new_state'] = [ + '#type' => 'select', + '#title' => $this->t('Moderate'), + '#options' => $target_states, + ]; + + $form['revision_log'] = [ + '#type' => 'textfield', + '#title' => $this->t('Log message'), + '#size' => 30, + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Apply'), + ]; + + $form['#theme'] = ['entity_moderation_form']; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + /** @var ContentEntityInterface $entity */ + $entity = $form_state->get('entity'); + + $new_state = $form_state->getValue('new_state'); + + // @todo should we just just be updating the content moderation state + // entity? That would prevent setting the revision log. + $entity->moderation_state->target_id = $new_state; + $entity->revision_log = $form_state->getValue('revision_log'); + + $entity->save(); + + drupal_set_message($this->t('The moderation state has been updated.')); + + /** @var \Drupal\content_moderation\Entity\ModerationState $state */ + $state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state); + + // The page we're on likely won't be visible if we just set the entity to + // the default state, as we hide that latest-revision tab if there is no + // forward revision. Redirect to the canonical URL instead, since that will + // still exist. + if ($state->isDefaultRevisionState()) { + $form_state->setRedirectUrl($entity->toUrl('canonical')); + } + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php new file mode 100644 index 0000000..43e2b36 --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php @@ -0,0 +1,49 @@ +t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.moderation_state.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + drupal_set_message($this->t( + 'Moderation state %label deleted.', + ['%label' => $this->entity->label()] + )); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateForm.php b/core/modules/content_moderation/src/Form/ModerationStateForm.php new file mode 100644 index 0000000..5922554 --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateForm.php @@ -0,0 +1,82 @@ +entity; + $form['label'] = array( + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $moderation_state->label(), + '#description' => $this->t("Label for the Moderation state."), + '#required' => TRUE, + ); + + $form['id'] = array( + '#type' => 'machine_name', + '#default_value' => $moderation_state->id(), + '#machine_name' => array( + 'exists' => [ModerationState::class, 'load'], + ), + '#disabled' => !$moderation_state->isNew(), + ); + + $form['published'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Published'), + '#description' => $this->t('When content reaches this state it should be published.'), + '#default_value' => $moderation_state->isPublishedState(), + ]; + + $form['default_revision'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Default revision'), + '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'), + '#default_value' => $moderation_state->isDefaultRevisionState(), + // @todo Add form #state to force "make default" on when "published" is + // on for a state. + // @see https://www.drupal.org/node/2645614 + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $moderation_state = $this->entity; + $status = $moderation_state->save(); + + switch ($status) { + case SAVED_NEW: + drupal_set_message($this->t('Created the %label Moderation state.', [ + '%label' => $moderation_state->label(), + ])); + break; + + default: + drupal_set_message($this->t('Saved the %label Moderation state.', [ + '%label' => $moderation_state->label(), + ])); + } + $form_state->setRedirectUrl($moderation_state->toUrl('collection')); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php new file mode 100644 index 0000000..f153f1f --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php @@ -0,0 +1,49 @@ +t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.moderation_state_transition.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + drupal_set_message($this->t( + 'Moderation transition %label deleted.', + ['%label' => $this->entity->label()] + )); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php new file mode 100644 index 0000000..d7444ef --- /dev/null +++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php @@ -0,0 +1,151 @@ +entityTypeManager = $entity_type_manager; + $this->queryFactory = $query_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('entity_type.manager'), $container->get('entity.query')); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + + /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */ + $moderation_state_transition = $this->entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $moderation_state_transition->label(), + '#description' => $this->t("Label for the Moderation state transition."), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $moderation_state_transition->id(), + '#machine_name' => [ + 'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load', + ], + '#disabled' => !$moderation_state_transition->isNew(), + ]; + + $options = []; + foreach ($this->entityTypeManager->getStorage('moderation_state') + ->loadMultiple() as $moderation_state) { + $options[$moderation_state->id()] = $moderation_state->label(); + } + + $form['container'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['container-inline'], + ], + ]; + + $form['container']['stateFrom'] = [ + '#type' => 'select', + '#title' => $this->t('Transition from'), + '#options' => $options, + '#required' => TRUE, + '#empty_option' => $this->t('-- Select --'), + '#default_value' => $moderation_state_transition->getFromState(), + ]; + + $form['container']['stateTo'] = [ + '#type' => 'select', + '#options' => $options, + '#required' => TRUE, + '#title' => $this->t('Transition to'), + '#empty_option' => $this->t('-- Select --'), + '#default_value' => $moderation_state_transition->getToState(), + ]; + + // Make sure there's always at least a wide enough delta on weight to cover + // the current value or the total number of transitions. That way we + // never end up forcing a transition to change its weight needlessly. + $num_transitions = $this->queryFactory->get('moderation_state_transition') + ->count() + ->execute(); + $delta = max(abs($moderation_state_transition->getWeight()), $num_transitions); + + $form['weight'] = [ + '#type' => 'weight', + '#delta' => $delta, + '#options' => $options, + '#title' => $this->t('Weight'), + '#default_value' => $moderation_state_transition->getWeight(), + '#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $moderation_state_transition = $this->entity; + $status = $moderation_state_transition->save(); + + switch ($status) { + case SAVED_NEW: + drupal_set_message($this->t('Created the %label Moderation state transition.', [ + '%label' => $moderation_state_transition->label(), + ])); + break; + + default: + drupal_set_message($this->t('Saved the %label Moderation state transition.', [ + '%label' => $moderation_state_transition->label(), + ])); + } + $form_state->setRedirectUrl($moderation_state_transition->toUrl('collection')); + } + +} diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php new file mode 100644 index 0000000..2c2c8a9 --- /dev/null +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -0,0 +1,213 @@ +entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public function isModeratableEntity(EntityInterface $entity) { + if (!$entity instanceof ContentEntityInterface) { + return FALSE; + } + + return $this->isModeratableBundle($entity->getEntityType(), $entity->bundle()); + } + + /** + * {@inheritdoc} + */ + public function isModeratableEntityType(EntityTypeInterface $entity_type) { + return $entity_type->hasHandlerClass('moderation'); + } + + /** + * {@inheritdoc} + */ + public function loadBundleEntity($bundle_entity_type_id, $bundle_id) { + if ($bundle_entity_type_id) { + return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id); + } + } + + /** + * {@inheritdoc} + */ + public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle) { + if ($bundle_entity = $this->loadBundleEntity($entity_type->getBundleEntityType(), $bundle)) { + return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE); + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function selectRevisionableEntityTypes(array $entity_types) { + return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) { + return ($type instanceof ConfigEntityTypeInterface) + && ($bundle_of = $type->get('bundle_of')) + && $entity_types[$bundle_of]->isRevisionable(); + }); + } + + /** + * {@inheritdoc} + */ + public function selectRevisionableEntities(array $entity_types) { + return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) { + return ($type instanceof ContentEntityTypeInterface) + && $type->isRevisionable() + && $type->getBundleEntityType(); + }); + } + + /** + * {@inheritdoc} + */ + public function isBundleForModeratableEntity(EntityInterface $entity) { + $type = $entity->getEntityType(); + + return + $type instanceof ConfigEntityTypeInterface + && ($bundle_of = $type->get('bundle_of')) + && $this->entityTypeManager->getDefinition($bundle_of)->isRevisionable() + && $this->currentUser->hasPermission('administer moderation states'); + } + + /** + * {@inheritdoc} + */ + public function isModeratedEntityForm(FormInterface $form_object) { + return $form_object instanceof ContentEntityFormInterface + && $this->isModeratableEntity($form_object->getEntity()); + } + + /** + * {@inheritdoc} + */ + public function isRevisionableBundleForm(FormInterface $form_object) { + // We really shouldn't be checking for a base class, but core lacks an + // interface here. When core adds a better way to determine if we're on + // a Bundle configuration form we should switch to that. + if ($form_object instanceof BundleEntityFormBase) { + $bundle_of = $form_object->getEntity()->getEntityType()->getBundleOf(); + $type = $this->entityTypeManager->getDefinition($bundle_of); + return $type->isRevisionable(); + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getLatestRevision($entity_type_id, $entity_id) { + if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) { + return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id); + } + } + + /** + * {@inheritdoc} + */ + public function getLatestRevisionId($entity_type_id, $entity_id) { + if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) { + $revision_ids = $storage->getQuery() + ->allRevisions() + ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id) + ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC') + ->range(0, 1) + ->execute(); + if ($revision_ids) { + $revision_id = array_keys($revision_ids)[0]; + return $revision_id; + } + } + } + + /** + * {@inheritdoc} + */ + public function getDefaultRevisionId($entity_type_id, $entity_id) { + if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) { + $revision_ids = $storage->getQuery() + ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id) + ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC') + ->range(0, 1) + ->execute(); + if ($revision_ids) { + $revision_id = array_keys($revision_ids)[0]; + return $revision_id; + } + } + } + + /** + * {@inheritdoc} + */ + public function isLatestRevision(ContentEntityInterface $entity) { + return $entity->getRevisionId() == $this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()); + } + + /** + * {@inheritdoc} + */ + public function hasForwardRevision(ContentEntityInterface $entity) { + return $this->isModeratableEntity($entity) + && !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id())); + } + + /** + * {@inheritdoc} + */ + public function isLiveRevision(ContentEntityInterface $entity) { + return $this->isLatestRevision($entity) + && $entity->isDefaultRevision() + && $entity->moderation_state->entity + && $entity->moderation_state->entity->isPublishedState(); + } + +} diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php new file mode 100644 index 0000000..b203792 --- /dev/null +++ b/core/modules/content_moderation/src/ModerationInformationInterface.php @@ -0,0 +1,212 @@ +orIf($admin_access); + } + + return $admin_access; + } + + /** + * {@inheritdoc} + */ + protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) { + return AccessResult::allowedIfHasPermission($account, 'administer moderation states'); + } + +} diff --git a/core/modules/content_moderation/src/ModerationStateInterface.php b/core/modules/content_moderation/src/ModerationStateInterface.php new file mode 100644 index 0000000..99f664f --- /dev/null +++ b/core/modules/content_moderation/src/ModerationStateInterface.php @@ -0,0 +1,28 @@ +t('Moderation state'); + $header['id'] = $this->t('Machine name'); + + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $row['label'] = $entity->label(); + $row['id']['#markup'] = $entity->id(); + + return $row + parent::buildRow($entity); + } + +} diff --git a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php new file mode 100644 index 0000000..91b5b13 --- /dev/null +++ b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php @@ -0,0 +1,36 @@ +get('entity.manager')->getStorage($entity_type->id()), + $container->get('entity.manager')->getStorage('moderation_state'), + $container->get('entity.manager')->getStorage('user_role') + ); + } + + /** + * Constructs a new ModerationStateTransitionListBuilder. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * Entity Type. + * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage + * Moderation state transition entity storage. + * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage + * Moderation state entity storage. + * @param \Drupal\user\RoleStorageInterface $role_storage + * The role storage. + */ + public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage, RoleStorageInterface $role_storage) { + parent::__construct($entity_type, $transition_storage); + $this->stateStorage = $state_storage; + $this->roleStorage = $role_storage; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'content_moderation_transition_list'; + } + + /** + * {@inheritdoc} + */ + public function buildHeader() { + $header['to'] = $this->t('To state'); + $header['label'] = $this->t('Button label'); + $header['roles'] = $this->t('Allowed roles'); + + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label(); + $row['label'] = $entity->label(); + $row['roles']['#markup'] = implode(', ', user_role_names(FALSE, 'use ' . $entity->id() . ' transition')); + + return $row + parent::buildRow($entity); + } + + /** + * {@inheritdoc} + */ + public function render() { + $build = parent::render(); + + $build['item'] = [ + '#type' => 'item', + '#markup' => $this->t('On this screen you can define transitions. Every time an entity is saved, it undergoes a transition. It is not possible to save an entity if it tries do a transition not defined here. Transitions do not necessarily mean a state change, it is possible to transition from a state to the same state but that transition needs to be defined here as well.'), + '#weight' => -5, + ]; + + return $build; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $this->entities = $this->load(); + + // Get all the moderation states and sort them by weight. + $states = $this->stateStorage->loadMultiple(); + uasort($states, array($this->entityType->getClass(), 'sort')); + + /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */ + $groups = array_fill_keys(array_keys($states), []); + foreach ($this->entities as $entity) { + $groups[$entity->getFromState()][] = $entity; + } + + foreach ($groups as $group_name => $entities) { + $form[$group_name] = [ + '#type' => 'details', + '#title' => $this->t('From @state to...', ['@state' => $states[$group_name]->label()]), + // Make sure that the first group is always open. + '#open' => $group_name === array_keys($groups)[0], + ]; + + $form[$group_name][$this->entitiesKey] = array( + '#type' => 'table', + '#header' => $this->buildHeader(), + '#empty' => t('There is no @label yet.', array('@label' => $this->entityType->getLabel())), + '#tabledrag' => array( + array( + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'weight', + ), + ), + ); + + $delta = 10; + // Change the delta of the weight field if have more than 20 entities. + if (!empty($this->weightKey)) { + $count = count($this->entities); + if ($count > 20) { + $delta = ceil($count / 2); + } + } + foreach ($entities as $entity) { + $row = $this->buildRow($entity); + if (isset($row['label'])) { + $row['label'] = array('#markup' => $row['label']); + } + if (isset($row['weight'])) { + $row['weight']['#delta'] = $delta; + } + $form[$group_name][$this->entitiesKey][$entity->id()] = $row; + } + } + + $form['actions']['#type'] = 'actions'; + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Save order'), + '#button_type' => 'primary', + ); + + return $form; + } + +} diff --git a/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php new file mode 100644 index 0000000..6b97d99 --- /dev/null +++ b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php @@ -0,0 +1,107 @@ +moderationInformation = $moderation_info; + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + return $this->hasForwardRevisionFlag($definition) || $this->isEditFormPage($route); + } + + /** + * Determines if the route definition includes a forward-revision flag. + * + * This is a custom flag defined by WBM to load forward revisions rather than + * the default revision on a given route. + * + * @param array $definition + * The parameter definition provided in the route options. + * + * @return bool + * TRUE if the forward revision flag is set, FALSE otherwise. + */ + protected function hasForwardRevisionFlag(array $definition) { + return (isset($definition['load_forward_revision']) && $definition['load_forward_revision']); + } + + /** + * Determines if a given route is the edit-form for an entity. + * + * @param \Symfony\Component\Routing\Route $route + * The route definition. + * + * @return bool + * Returns TRUE if the route is the edit form of an entity, FALSE otherwise. + */ + protected function isEditFormPage(Route $route) { + if ($default = $route->getDefault('_entity_form')) { + // If no operation is provided, use 'default'. + $default .= '.default'; + list($entity_type_id, $operation) = explode('.', $default); + if (!$this->entityManager->hasDefinition($entity_type_id)) { + return FALSE; + } + $entity_type = $this->entityManager->getDefinition($entity_type_id); + return $operation == 'edit' && $entity_type && $entity_type->isRevisionable(); + } + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults) { + $entity = parent::convert($value, $definition, $name, $defaults); + + if ($entity && $this->moderationInformation->isModeratableEntity($entity) && !$this->moderationInformation->isLatestRevision($entity)) { + $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults); + $latest_revision = $this->moderationInformation->getLatestRevision($entity_type_id, $value); + if ($latest_revision->isRevisionTranslationAffected()) { + $entity = $latest_revision; + } + // If the entity type is translatable, ensure we return the proper + // translation object for the current context. + if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) { + $entity = $this->entityManager->getTranslationFromContext($entity, NULL, array('operation' => 'entity_upcast')); + } + } + + return $entity; + } + +} diff --git a/core/modules/content_moderation/src/Permissions.php b/core/modules/content_moderation/src/Permissions.php new file mode 100644 index 0000000..f23ccc7 --- /dev/null +++ b/core/modules/content_moderation/src/Permissions.php @@ -0,0 +1,43 @@ + $transition) { + $perms['use ' . $id . ' transition'] = [ + 'title' => $this->t('Use the %transition_name transition', [ + '%transition_name' => $transition->label(), + ]), + 'description' => $this->t('Move content from %from state to %to state.', [ + '%from' => $states[$transition->getFromState()]->label(), + '%to' => $states[$transition->getToState()]->label(), + ]), + ]; + } + + return $perms; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php new file mode 100644 index 0000000..a85bac6 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php @@ -0,0 +1,75 @@ +moderationInfo = $moderation_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + if ($entity && $this->moderationInfo->isModeratableEntity($entity)) { + drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.')); + return; + } + + parent::execute($entity); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + $result = parent::access($object, $account, TRUE) + ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object)); + + return $return_as_object ? $result : $result->isAllowed(); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php new file mode 100644 index 0000000..b0fbd87 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php @@ -0,0 +1,75 @@ +moderationInfo = $moderation_info; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + if ($entity && $this->moderationInfo->isModeratableEntity($entity)) { + drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.')); + return; + } + + parent::execute($entity); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + $result = parent::access($object, $account, TRUE) + ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object)); + + return $return_as_object ? $result : $result->isAllowed(); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php new file mode 100644 index 0000000..64d5ed9 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php @@ -0,0 +1,123 @@ +entityTypeManager = $entity_type_manager; + $this->stringTranslation = $string_translation; + $this->basePluginId = $base_plugin_id; + $this->moderationInfo = $moderation_information; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $base_plugin_id, + $container->get('entity_type.manager'), + $container->get('string_translation'), + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + + foreach ($this->moderatableEntityTypeDefinitions() as $entity_type_id => $entity_type) { + $this->derivatives["$entity_type_id.moderation_tab"] = [ + 'route_name' => "entity.$entity_type_id.moderation", + 'title' => $this->t('Manage moderation'), + // @todo - are we sure they all have an edit_form? + 'base_route' => "entity.$entity_type_id.edit_form", + 'weight' => 30, + ] + $base_plugin_definition; + } + + $latest_version_entities = array_filter($this->moderatableEntityDefinitions(), function (EntityTypeInterface $type) { + return $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; + } + + /** + * Returns an array of content entities that are potentially moderatable. + * + * @return EntityTypeInterface[] + * An array of just those entities we care about. + */ + protected function moderatableEntityDefinitions() { + return $this->moderationInfo->selectRevisionableEntities($this->entityTypeManager->getDefinitions()); + } + + /** + * Returns entity types that represent bundles that can be moderated. + * + * @return EntityTypeInterface[] + * An array of entity types that represent bundles that can be moderated. + */ + protected function moderatableEntityTypeDefinitions() { + return $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions()); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php new file mode 100644 index 0000000..f2ab789 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php @@ -0,0 +1,249 @@ +moderationStateTransitionEntityQuery = $entity_query; + $this->moderationStateTransitionStorage = $moderation_state_transition_storage; + $this->moderationStateStorage = $moderation_state_storage; + $this->entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + $this->moderationInformation = $moderation_information; + $this->validator = $validator; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('current_user'), + $container->get('entity_type.manager'), + $container->get('entity_type.manager')->getStorage('moderation_state'), + $container->get('entity_type.manager')->getStorage('moderation_state_transition'), + $container->get('entity.query')->get('moderation_state_transition', 'AND'), + $container->get('content_moderation.moderation_information'), + $container->get('content_moderation.state_transition_validation') + ); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + /** @var ContentEntityInterface $entity */ + $entity = $items->getEntity(); + + /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle_entity */ + $bundle_entity = $this->entityTypeManager->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle()); + if (!$this->moderationInformation->isModeratableEntity($entity)) { + // @todo write a test for this. + return $element + ['#access' => FALSE]; + } + + $default = $items->get($delta)->value ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE); + /** @var \Drupal\content_moderation\ModerationStateInterface $default_state */ + $default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default); + if (!$default || !$default_state) { + throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label())); + } + + $transitions = $this->validator->getValidTransitions($entity, $this->currentUser); + + $target_states = []; + /** @var \Drupal\content_moderation\Entity\ModerationStateTransition $transition */ + foreach ($transitions as $transition) { + $target_states[$transition->getToState()] = $transition->label(); + } + + // @todo write a test for this. + $element += [ + '#access' => FALSE, + '#type' => 'select', + '#options' => $target_states, + '#default_value' => $default, + '#published' => $default ? $default_state->isPublishedState() : FALSE, + '#key_column' => $this->column, + ]; + $element['#element_validate'][] = array(get_class($this), 'validateElement'); + + // Use the dropbutton. + $element['#process'][] = [get_called_class(), 'processActions']; + return $element; + } + + /** + * Entity builder updating the node moderation state with the submitted value. + * + * @param string $entity_type_id + * The entity type identifier. + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity updated with the submitted values. + * @param array $form + * The complete form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) { + $element = $form_state->getTriggeringElement(); + if (isset($element['#moderation_state'])) { + $entity->moderation_state->target_id = $element['#moderation_state']; + } + } + + /** + * Process callback to alter action buttons. + */ + public static function processActions($element, FormStateInterface $form_state, array &$form) { + + // We'll steal most of the button configuration from the default submit + // button. However, NodeForm also hides that button for admins (as it adds + // its own, too), so we have to restore it. + $default_button = $form['actions']['submit']; + $default_button['#access'] = TRUE; + + // Add a custom button for each transition we're allowing. The #dropbutton + // property tells FAPI to cluster them all together into a single widget. + $options = $element['#options']; + + $entity = $form_state->getFormObject()->getEntity(); + $translatable = !$entity->isNew() && $entity->isTranslatable(); + foreach ($options as $id => $label) { + $button = [ + '#dropbutton' => 'save', + '#moderation_state' => $id, + '#weight' => -10, + ]; + + $button['#value'] = $translatable + ? t('Save and @transition (this translation)', ['@transition' => $label]) + : t('Save and @transition', ['@transition' => $label]); + + $form['actions']['moderation_state_' . $id] = $button + $default_button; + } + + // Hide the default buttons, including the specialty ones added by + // NodeForm. + foreach (['publish', 'unpublish', 'submit'] as $key) { + $form['actions'][$key]['#access'] = FALSE; + unset($form['actions'][$key]['#dropbutton']); + } + + // Setup a callback to translate the button selection back into field + // widget, so that it will get saved properly. + $form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus']; + return $element; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + return parent::isApplicable($field_definition) && $field_definition->getName() === 'moderation_state'; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php new file mode 100644 index 0000000..644a76b --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -0,0 +1,82 @@ +getEntity(); + + if ($entity->id() && $entity->getRevisionId()) { + $revisions = \Drupal::service('entity.query')->get('content_moderation_state') + ->condition('content_entity_type_id', $entity->getEntityTypeId()) + ->condition('content_entity_id', $entity->id()) + ->condition('content_entity_revision_id', $entity->getRevisionId()) + ->allRevisions() + ->sort('revision_id', 'DESC') + ->execute(); + + if ($revision_to_load = key($revisions)) { + /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */ + $content_moderation_state = \Drupal::entityTypeManager() + ->getStorage('content_moderation_state') + ->loadRevision($revision_to_load); + + // Return the correct translation. + $langcode = $entity->language()->getId(); + if (!$content_moderation_state->hasTranslation($langcode)) { + $content_moderation_state->addTranslation($langcode); + } + if ($content_moderation_state->language()->getId() !== $langcode) { + $content_moderation_state = $content_moderation_state->getTranslation($langcode); + } + + return $content_moderation_state->get('moderation_state')->entity; + } + } + // It is possible that the bundle does not exist at this point. For example, + // the node type form creates a fake Node entity to get default values. + // @see \Drupal\node\NodeTypeForm::form() + $bundle_entity = \Drupal::service('content_moderation.moderation_information') + ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); + if ($bundle_entity && ($default = $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state'))) { + return ModerationState::load($default); + } + } + + /** + * {@inheritdoc} + */ + public function get($index) { + if ($index !== 0) { + throw new \InvalidArgumentException('An entity can not have multiple moderation states at the same time.'); + } + // Compute the value of the moderation state. + if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) { + $moderation_state = $this->getModerationState(); + // Do not store NULL values in the static cache. + if ($moderation_state) { + $this->list[$index] = $this->createItem($index, ['entity' => $moderation_state]); + } + } + + return isset($this->list[$index]) ? $this->list[$index] : NULL; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php new file mode 100644 index 0000000..1a630c2 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php @@ -0,0 +1,104 @@ +stringTranslation = $string_translation; + $this->moderationInfo = $moderation_information; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('string_translation'), + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function getRouteParameters(RouteMatchInterface $route_match) { + // Override the node here with the latest revision. + $this->entity = $route_match->getParameter($this->pluginDefinition['entity_type_id']); + return parent::getRouteParameters($route_match); + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + if (!$this->moderationInfo->isModeratableEntity($this->entity)) { + // Moderation isn't enabled. + return parent::getTitle(); + } + + // @todo write a test for this. + return $this->moderationInfo->isLiveRevision($this->entity) + ? $this->t('New draft') + : $this->t('Edit draft'); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + // @todo write a test for this. + $tags = parent::getCacheTags(); + // Tab changes if node or node-type is modified. + $tags = array_merge($tags, $this->entity->getCacheTags()); + $tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle(); + return $tags; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php new file mode 100644 index 0000000..c2c373f --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php @@ -0,0 +1,19 @@ +validation = $validation; + $this->entityTypeManager = $entity_type_manager; + $this->moderationInformation = $moderation_information; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('content_moderation.state_transition_validation'), + $container->get('content_moderation.moderation_information') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $value->getEntity(); + + // Ignore entities that are not subject to moderation anyway. + if (!$this->moderationInformation->isModeratableEntity($entity)) { + return; + } + + // Ignore entities that are being created for the first time. + if ($entity->isNew()) { + return; + } + + // Ignore entities that are being moderated for the first time, such as + // when they existed before moderation was enabled for this entity type. + if ($this->isFirstTimeModeration($entity)) { + return; + } + + $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); + if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) { + $original_entity = $original_entity->getTranslation($entity->language()->getId()); + } + + if ($entity->moderation_state->target_id) { + $new_state_id = $entity->moderation_state->target_id; + } + else { + $new_state_id = $default = $this->moderationInformation + ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()) + ->getThirdPartySetting('content_moderation', 'default_moderation_state'); + } + if ($new_state_id) { + $new_state = ModerationStateEntity::load($new_state_id); + } + // @todo - what if $new_state_id references something that does not exist or + // is null. + if (!$this->validation->isTransitionAllowed($original_entity->moderation_state->entity, $new_state)) { + $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $new_state->label()]); + } + } + + /** + * Determines if this entity is being moderated for the first time. + * + * If the previous version of the entity has no moderation state, we assume + * that means it predates the presence of moderation states. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being moderated. + * + * @return bool + * TRUE if this is the entity's first time being moderated, FALSE otherwise. + */ + protected function isFirstTimeModeration(EntityInterface $entity) { + $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); + + $original_id = $original_entity->moderation_state->target_id; + + return !($entity->moderation_state->target_id && $original_entity && $original_id); + } + +} diff --git a/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php new file mode 100644 index 0000000..6440019 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php @@ -0,0 +1,136 @@ +entityTypeManager = $entity_type_manager; + $this->joinHandler = $join_handler; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, $plugin_id, $plugin_definition, + $container->get('entity_type.manager'), + $container->get('plugin.manager.views.join'), + $container->get('database') + ); + } + + /** + * {@inheritdoc} + */ + public function adminSummary() { + } + + /** + * {@inheritdoc} + */ + protected function operatorForm(&$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function canExpose() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function query() { + // The table doesn't exist until a moderated node has been saved at least + // once. Just in case, disable this filter until then. Note that this means + // the view will still show all revisions, not just latest, but this is + // sufficiently edge-case-y that it's probably not worth the time to + // handle more robustly. + if (!$this->connection->schema()->tableExists('content_revision_tracker')) { + return; + } + + $table = $this->ensureMyTable(); + + /** @var \Drupal\views\Plugin\views\query\Sql $query */ + $query = $this->query; + + $definition = $this->entityTypeManager->getDefinition($this->getEntityType()); + $keys = $definition->getKeys(); + + $definition = [ + 'table' => 'content_revision_tracker', + 'type' => 'INNER', + 'field' => 'entity_id', + 'left_table' => $table, + 'left_field' => $keys['id'], + 'extra' => [ + ['left_field' => $keys['langcode'], 'field' => 'langcode'], + ['left_field' => $keys['revision'], 'field' => 'revision_id'], + ['field' => 'entity_type', 'value' => $this->getEntityType()], + ], + ]; + + $join = $this->joinHandler->createInstance('standard', $definition); + + $query->ensureTable('content_revision_tracker', $this->relationship, $join); + } + +} diff --git a/core/modules/content_moderation/src/RevisionTracker.php b/core/modules/content_moderation/src/RevisionTracker.php new file mode 100644 index 0000000..2011237 --- /dev/null +++ b/core/modules/content_moderation/src/RevisionTracker.php @@ -0,0 +1,152 @@ +connection = $connection; + $this->tableName = $table; + } + + /** + * {@inheritdoc} + */ + public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) { + try { + $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id); + } + catch (DatabaseExceptionWrapper $e) { + $this->ensureTableExists(); + $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id); + } + + return $this; + } + + /** + * Records the latest revision of a given entity. + * + * @param string $entity_type_id + * The machine name of the type of entity. + * @param string $entity_id + * The Entity ID in question. + * @param string $langcode + * The langcode of the revision we're saving. Each language has its own + * effective tree of entity revisions, so in different languages + * different revisions will be "latest". + * @param int $revision_id + * The revision ID that is now the latest revision. + * + * @return int + * One of the valid returns from a merge query's execute method. + */ + protected function recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) { + return $this->connection->merge($this->tableName) + ->keys([ + 'entity_type' => $entity_type_id, + 'entity_id' => $entity_id, + 'langcode' => $langcode, + ]) + ->fields([ + 'revision_id' => $revision_id, + ]) + ->execute(); + } + + /** + * Checks if the table exists and create it if not. + * + * @return bool + * TRUE if the table was created, FALSE otherwise. + */ + protected function ensureTableExists() { + try { + if (!$this->connection->schema()->tableExists($this->tableName)) { + $this->connection->schema()->createTable($this->tableName, $this->schemaDefinition()); + return TRUE; + } + } + catch (SchemaObjectExistsException $e) { + // If another process has already created the table, attempting to + // recreate it will throw an exception. In this case just catch the + // exception and do nothing. + return TRUE; + } + return FALSE; + } + + /** + * Defines the schema for the tracker table. + * + * @return array + * The schema API definition for the SQL storage table. + */ + protected function schemaDefinition() { + $schema = [ + 'description' => 'Tracks the latest revision for any entity', + 'fields' => [ + 'entity_type' => [ + 'description' => 'The entity type', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + 'entity_id' => [ + 'description' => 'The entity ID', + 'type' => 'int', + 'length' => 255, + 'not null' => TRUE, + 'default' => 0, + ], + 'langcode' => [ + 'description' => 'The language of the entity revision', + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + 'default' => '', + ], + 'revision_id' => [ + 'description' => 'The latest revision ID for this entity', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + ], + 'primary key' => ['entity_type', 'entity_id', 'langcode'], + ]; + + return $schema; + } + +} diff --git a/core/modules/content_moderation/src/RevisionTrackerInterface.php b/core/modules/content_moderation/src/RevisionTrackerInterface.php new file mode 100644 index 0000000..2b7cf95 --- /dev/null +++ b/core/modules/content_moderation/src/RevisionTrackerInterface.php @@ -0,0 +1,28 @@ +entityFieldManager = $entity_manager; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static( + $container->get('entity_field.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getRoutes(EntityTypeInterface $entity_type) { + $collection = new RouteCollection(); + + if ($moderation_route = $this->getLatestVersionRoute($entity_type)) { + $entity_type_id = $entity_type->id(); + $collection->add("entity.{$entity_type_id}.latest_version", $moderation_route); + } + + return $collection; + } + + /** + * Gets the moderation-form route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getLatestVersionRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) { + $entity_type_id = $entity_type->id(); + $route = new Route($entity_type->getLinkTemplate('latest-version')); + $route + ->addDefaults([ + '_entity_view' => "{$entity_type_id}.full", + '_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title', + ]) + // If the entity type is a node, unpublished content will be visible + // if the user has the "view all unpublished content" permission. + ->setRequirement('_entity_access', "{$entity_type_id}.view") + ->setRequirement('_permission', 'view latest version,view any unpublished content') + ->setRequirement('_content_moderation_latest_version', 'TRUE') + ->setOption('_content_moderation_entity_type', $entity_type_id) + ->setOption('parameters', [ + $entity_type_id => [ + 'type' => 'entity:' . $entity_type_id, + 'load_forward_revision' => 1, + ], + ]); + + // Entity types with serial IDs can specify this in their route + // requirements, improving the matching process. + if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') { + $route->setRequirement($entity_type_id, '\d+'); + } + return $route; + } + } + + /** + * Gets the type of the ID key for a given entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * An entity type. + * + * @return string|null + * The type of the ID key for a given entity type, or NULL if the entity + * type does not support fields. + */ + protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) { + if (!$entity_type->isSubclassOf(FieldableEntityInterface::class)) { + return NULL; + } + + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id()); + return $field_storage_definitions[$entity_type->getKey('id')]->getType(); + } + +} diff --git a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php new file mode 100644 index 0000000..c722a67 --- /dev/null +++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php @@ -0,0 +1,59 @@ +getModerationFormRoute($entity_type)) { + $entity_type_id = $entity_type->id(); + $collection->add("entity.{$entity_type_id}.moderation", $moderation_route); + } + + return $collection; + } + + /** + * Gets the moderation-form route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getModerationFormRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) { + $entity_type_id = $entity_type->id(); + + $route = new Route($entity_type->getLinkTemplate('moderation-form')); + + // @todo Come up with a new permission. + $route + ->setDefaults([ + '_entity_form' => "{$entity_type_id}.moderation", + '_title' => 'Moderation', + ]) + ->setRequirement('_permission', 'administer moderation states') + ->setOption('parameters', [ + $entity_type_id => ['type' => 'entity:' . $entity_type_id], + ]); + + return $route; + } + } + +} diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php new file mode 100644 index 0000000..b5d0e58 --- /dev/null +++ b/core/modules/content_moderation/src/StateTransitionValidation.php @@ -0,0 +1,247 @@ +entityTypeManager = $entity_type_manager; + $this->queryFactory = $query_factory; + } + + /** + * Computes a mapping of possible transitions. + * + * This method is uncached and will recalculate the list on every request. + * In most cases you want to use getPossibleTransitions() instead. + * + * @see static::getPossibleTransitions() + * + * @return array[] + * An array containing all possible transitions. Each entry is keyed by the + * "from" state, and the value is an array of all legal "to" states based + * on the currently defined transition objects. + */ + protected function calculatePossibleTransitions() { + $transitions = $this->transitionStorage()->loadMultiple(); + + $possible_transitions = []; + /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */ + foreach ($transitions as $transition) { + $possible_transitions[$transition->getFromState()][] = $transition->getToState(); + } + return $possible_transitions; + } + + /** + * Returns a mapping of possible transitions. + * + * @return array[] + * An array containing all possible transitions. Each entry is keyed by the + * "from" state, and the value is an array of all legal "to" states based + * on the currently defined transition objects. + */ + protected function getPossibleTransitions() { + if (empty($this->possibleTransitions)) { + $this->possibleTransitions = $this->calculatePossibleTransitions(); + } + return $this->possibleTransitions; + } + + /** + * {@inheritdoc} + */ + public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) { + $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); + + $states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); + + /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ + $current_state = $entity->moderation_state->entity; + + $all_transitions = $this->getPossibleTransitions(); + $destination_ids = $all_transitions[$current_state->id()]; + + $destination_ids = array_intersect($states_for_bundle, $destination_ids); + $destinations = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($destination_ids); + + return array_filter($destinations, function(ModerationStateInterface $destination_state) use ($current_state, $user) { + return $this->userMayTransition($current_state, $destination_state, $user); + }); + } + + /** + * {@inheritdoc} + */ + public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) { + $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); + + /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ + $current_state = $entity->moderation_state->entity; + $current_state_id = $current_state ? $current_state->id() : $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state'); + + // Determine the states that are legal on this bundle. + $legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); + + // Legal transitions include those that are possible from the current state, + // filtered by those whose target is legal on this bundle and that the + // user has access to execute. + $transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) { + return in_array($transition->getToState(), $legal_bundle_states) + && $user->hasPermission('use ' . $transition->id() . ' transition'); + }); + + return $transitions; + } + + /** + * Returns a list of possible transitions from a given state. + * + * This list is based only on those transitions that exist, not what + * transitions are legal in a given context. + * + * @param string $state_name + * The machine name of the state from which we are transitioning. + * + * @return ModerationStateTransition[] + * A list of possible transitions from a given state. + */ + protected function getTransitionsFrom($state_name) { + $result = $this->transitionStateQuery() + ->condition('stateFrom', $state_name) + ->sort('weight') + ->execute(); + + return $this->transitionStorage()->loadMultiple($result); + } + + /** + * {@inheritdoc} + */ + public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user) { + if ($transition = $this->getTransitionFromStates($from, $to)) { + return $user->hasPermission('use ' . $transition->id() . ' transition'); + } + return FALSE; + } + + /** + * Returns the transition object that transitions from one state to another. + * + * @param \Drupal\content_moderation\ModerationStateInterface $from + * The origin state. + * @param \Drupal\content_moderation\ModerationStateInterface $to + * The destination state. + * + * @return ModerationStateTransition|null + * A transition object, or NULL if there is no such transition. + */ + protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) { + $from = $this->transitionStateQuery() + ->condition('stateFrom', $from->id()) + ->condition('stateTo', $to->id()) + ->execute(); + + $transitions = $this->transitionStorage()->loadMultiple($from); + + if ($transitions) { + return current($transitions); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to) { + $allowed_transitions = $this->calculatePossibleTransitions(); + if (isset($allowed_transitions[$from->id()])) { + return in_array($to->id(), $allowed_transitions[$from->id()], TRUE); + } + return FALSE; + } + + /** + * Returns a transition state entity query. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * A transition state entity query. + */ + protected function transitionStateQuery() { + return $this->queryFactory->get('moderation_state_transition', 'AND'); + } + + /** + * Returns the transition entity storage service. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + * The transition state entity storage. + */ + protected function transitionStorage() { + return $this->entityTypeManager->getStorage('moderation_state_transition'); + } + + /** + * Returns the state entity storage service. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + * The moderation state entity storage. + */ + protected function stateStorage() { + return $this->entityTypeManager->getStorage('moderation_state'); + } + + /** + * Loads a specific bundle entity. + * + * @param string $bundle_entity_type_id + * The bundle entity type ID. + * @param string $bundle_id + * The bundle ID. + * + * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null + * The specific bundle entity. + */ + protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) { + return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id); + } + +} diff --git a/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/core/modules/content_moderation/src/StateTransitionValidationInterface.php new file mode 100644 index 0000000..5ef0dd1 --- /dev/null +++ b/core/modules/content_moderation/src/StateTransitionValidationInterface.php @@ -0,0 +1,71 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE, [ + 'draft', + 'published', + ], 'draft'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Tests the moderation form that shows on the latest version page. + * + * The latest version page only shows if there is a forward revision. There + * is only a forward revision if a draft revision is created on a node where + * the default revision is not a published moderation state. + * + * @see \Drupal\content_moderation\EntityOperations + * @see \Drupal\content_moderation\Tests\ModerationStateBlockTest::testCustomBlockModeration + */ + public function testModerationForm() { + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], t('Save and Create New Draft')); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $canonical_path = sprintf('node/%d', $node->id()); + $edit_path = sprintf('node/%d/edit', $node->id()); + $latest_version_path = sprintf('node/%d/latest', $node->id()); + + $this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content')); + + // The latest version page should not show, because there is no forward + // revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Update the draft. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Second version of the content.', + ], t('Save and Create New Draft')); + + // The latest version page should not show, because there is still no + // forward revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Publish the draft. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Third version of the content.', + ], t('Save and Publish')); + + // The published view should not have a moderation form, because it is the + // default revision. + $this->drupalGet($canonical_path); + $this->assertResponse(200); + $this->assertNoText('Status', 'The node view page has no moderation form.'); + + // The latest version page should not show, because there is still no + // forward revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + + // Make a forward revision. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Fourth version of the content.', + ], t('Save and Create New Draft')); + + // The published view should not have a moderation form, because it is the + // default revision. + $this->drupalGet($canonical_path); + $this->assertResponse(200); + $this->assertNoText('Status', 'The node view page has no moderation form.'); + + // The latest version page should show the moderation form and have "Draft" + // status, because the forward revision is in "Draft". + $this->drupalGet($latest_version_path); + $this->assertResponse(200); + $this->assertText('Status', 'Form text found on the latest-version page.'); + $this->assertText('Draft', 'Correct status found on the latest-version page.'); + + // Submit the moderation form to change status to published. + $this->drupalPostForm($latest_version_path, [ + 'new_state' => 'published', + ], t('Apply')); + + // The latest version page should not show, because there is no + // forward revision. + $this->drupalGet($latest_version_path); + $this->assertResponse(403); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php new file mode 100644 index 0000000..2af08be --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php @@ -0,0 +1,186 @@ +drupalLogin($this->rootUser); + + // Enable moderation on Article node type. + $this->createContentTypeFromUi( + 'Article', + 'article', + TRUE, + ['draft', 'published', 'archived'], + 'draft' + ); + + // Add French language. + $edit = [ + 'predefined_langcode' => 'fr', + ]; + $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language')); + + // Enable content translation on articles. + $this->drupalGet('admin/config/regional/content-language'); + $edit = [ + 'entity_types[node]' => TRUE, + 'settings[node][article][translatable]' => TRUE, + 'settings[node][article][settings][language][language_alterable]' => TRUE, + ]; + $this->drupalPostForm(NULL, $edit, t('Save configuration')); + + // Adding languages requires a container rebuild in the test running + // environment so that multilingual services are used. + $this->rebuildContainer(); + + // Create a published article in English. + $edit = [ + 'title[0][value]' => 'Published English node', + 'langcode[0][value]' => 'en', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Publish')); + $this->assertText(t('Article Published English node has been created.')); + $english_node = $this->drupalGetNodeByTitle('Published English node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'French node Draft', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + // Here the error has occured "The website encountered an unexpected error. + // Please try again later." + // If the translation has got lost. + $this->assertText(t('Article French node Draft has been updated.')); + + // Create an article in English. + $edit = [ + 'title[0][value]' => 'English node', + 'langcode[0][value]' => 'en', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft')); + $this->assertText(t('Article English node has been created.')); + $english_node = $this->drupalGetNodeByTitle('English node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'French node', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article French node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('English node', TRUE); + + // Publish the English article and check that the translation stays + // unpublished. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article English node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('English node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual('French node', $french_node->label()); + + $this->assertEqual($english_node->moderation_state->target_id, 'published'); + $this->assertTrue($english_node->isPublished()); + $this->assertEqual($french_node->moderation_state->target_id, 'draft'); + $this->assertFalse($french_node->isPublished()); + + // Create another article with its translation. This time we will publish + // the translation first. + $edit = [ + 'title[0][value]' => 'Another node', + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft')); + $this->assertText(t('Article Another node has been created.')); + $english_node = $this->drupalGetNodeByTitle('Another node'); + + // Add a French translation. + $this->drupalGet('node/' . $english_node->id() . '/translations'); + $this->clickLink(t('Add')); + $edit = [ + 'title[0][value]' => 'Translated node', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article Translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + + // Publish the translation and check that the source language version stays + // unpublished. + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article Translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($english_node->moderation_state->target_id, 'draft'); + $this->assertFalse($english_node->isPublished()); + + // Now check that we can create a new draft of the translation and then + // publish it. + $edit = [ + 'title[0][value]' => 'New draft of translated node', + ]; + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)')); + $this->assertText(t('Article New draft of translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.'); + + // Publish the draft. + $edit = [ + 'new_state' => 'published', + ]; + $this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply')); + $this->assertText(t('The moderation state has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertTrue($french_node->isPublished()); + $this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.'); + + // Publish the English article before testing the archive transition. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); + $this->assertText(t('Article Another node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $this->assertEqual($english_node->moderation_state->target_id, 'published'); + + // Archive the node and its translation. + $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)')); + $this->assertText(t('Article Another node has been updated.')); + $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)')); + $this->assertText(t('Article New draft of translated node has been updated.')); + $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); + $french_node = $english_node->getTranslation('fr'); + $this->assertEqual($english_node->moderation_state->target_id, 'archived'); + $this->assertFalse($english_node->isPublished()); + $this->assertEqual($french_node->moderation_state->target_id, 'archived'); + $this->assertFalse($french_node->isPublished()); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php new file mode 100644 index 0000000..001d6b5 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php @@ -0,0 +1,132 @@ + '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 at + // admin/structure/block/block-content/manage/basic/moderation. + $edit = [ + 'enable_moderation_state' => TRUE, + 'allowed_moderation_states_unpublished[draft]' => TRUE, + 'allowed_moderation_states_published[published]' => TRUE, + 'default_moderation_state' => 'draft', + ]; + $this->drupalPostForm('admin/structure/block/block-content/manage/basic/moderation', $edit, t('Save')); + $this->assertText(t('Your settings have been saved.')); + + // Create a custom block at block/add and save it as draft. + $body = 'Body of moderated block'; + $edit = [ + 'info[0][value]' => 'Moderated block', + 'body[0][value]' => $body, + ]; + $this->drupalPostForm('block/add', $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been created.')); + + // Place the block in the Sidebar First region. + $instance = array( + 'id' => 'moderated_block', + 'settings[label]' => $edit['info[0][value]'], + 'region' => 'sidebar_first', + ); + $block = BlockContent::load(1); + $url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default'); + $this->drupalPostForm($url, $instance, t('Save block')); + + // Navigate to home page and check that the block is visible. It should be + // visible because it is the default revision. + $this->drupalGet(''); + $this->assertText($body); + + // Update the block. + $updated_body = 'This is the new body value'; + $edit = [ + 'body[0][value]' => $updated_body, + ]; + $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been updated.')); + + // Navigate to the home page and check that the block shows the updated + // content. It should show the updated content because the block's default + // revision is not a published moderation state. + $this->drupalGet(''); + $this->assertText($updated_body); + + // Publish the block so we can create a forward revision. + $this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish')); + + // Create a forward revision. + $forward_revision_body = 'This is the forward revision body value'; + $edit = [ + 'body[0][value]' => $forward_revision_body, + ]; + $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft')); + $this->assertText(t('basic Moderated block has been updated.')); + + // Navigate to home page and check that the forward revision doesn't show, + // since it should not be set as the default revision. + $this->drupalGet(''); + $this->assertText($updated_body); + + // Open the latest tab and publish the new draft. + $edit = [ + 'new_state' => 'published', + ]; + $this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply')); + $this->assertText(t('The moderation state has been updated.')); + + // Navigate to home page and check that the forward revision is now the + // default revision and therefore visible. + $this->drupalGet(''); + $this->assertText($forward_revision_body); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php new file mode 100644 index 0000000..a819caf --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php @@ -0,0 +1,133 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUi( + 'Moderated content', + 'moderated_content', + TRUE, + ['draft', 'needs_review', 'published'], + 'draft' + ); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Tests creating and deleting content. + */ + public function testCreatingContent() { + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'moderated content', + ], t('Save and Create New Draft')); + $nodes = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties([ + 'title' => 'moderated content', + ]); + + if (!$nodes) { + $this->fail('Test node was not saved correctly.'); + return; + } + + $node = reset($nodes); + + $path = 'node/' . $node->id() . '/edit'; + // Set up published revision. + $this->drupalPostForm($path, [], t('Save and Publish')); + \Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]); + /* @var \Drupal\node\NodeInterface $node */ + $node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id()); + $this->assertTrue($node->isPublished()); + + // Verify that the state field is not shown. + $this->assertNoText('Published'); + + // Delete the node. + $this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete')); + $this->assertText(t('The Moderated content moderated content has been deleted.')); + } + + /** + * Tests edit form destinations. + */ + public function testFormSaveDestination() { + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], t('Save and Create New Draft')); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $edit_path = sprintf('node/%d/edit', $node->id()); + + // After saving, we should be at the canonical URL and viewing the first + // revision. + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('First version of the content.'); + + // Create a new draft; after saving, we should still be on the canonical + // URL, but viewing the second revision. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Second version of the content.', + ], t('Save and Create New Draft')); + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('Second version of the content.'); + + // Make a new published revision; after saving, we should be at the + // canonical URL. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Third version of the content.', + ], t('Save and Publish')); + $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()])); + $this->assertText('Third version of the content.'); + + // Make a new forward revision; after saving, we should be on the "Latest + // version" tab. + $this->drupalPostForm($edit_path, [ + 'body[0][value]' => 'Fourth version of the content.', + ], t('Save and Create New Draft')); + $this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()])); + $this->assertText('Fourth version of the content.'); + } + + /** + * Tests pagers aren't broken by content_moderation. + */ + public function testPagers() { + // Create 51 nodes to force the pager. + foreach (range(1, 51) as $delta) { + Node::create([ + 'type' => 'moderated_content', + 'uid' => $this->adminUser->id(), + 'title' => 'Node ' . $delta, + 'status' => 1, + 'moderation_state' => 'published', + ])->save(); + } + $this->drupalLogin($this->adminUser); + $this->drupalGet('admin/content'); + $element = $this->cssSelect('nav.pager li.is-active a'); + $url = (string) $element[0]['href']; + $query = []; + parse_str(parse_url($url, PHP_URL_QUERY), $query); + $this->assertEqual(0, $query['page']); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php new file mode 100644 index 0000000..debb32c --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php @@ -0,0 +1,69 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUi('Not moderated', 'not_moderated'); + $this->assertText('The content type Not moderated has been added.'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated'); + $this->drupalGet('node/add/not_moderated'); + $this->assertRaw('Save as unpublished'); + $this->drupalPostForm(NULL, [ + 'title[0][value]' => 'Test', + ], t('Save and publish')); + $this->assertText('Not moderated Test has been created.'); + } + + /** + * Tests enabling moderation on an existing node-type, with content. + */ + public function testEnablingOnExistingContent() { + // Create a node type that is not moderated. + $this->drupalLogin($this->adminUser); + $this->createContentTypeFromUi('Not moderated', 'not_moderated'); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated'); + + // Create content. + $this->drupalGet('node/add/not_moderated'); + $this->drupalPostForm(NULL, [ + 'title[0][value]' => 'Test', + ], t('Save and publish')); + $this->assertText('Not moderated Test has been created.'); + + // Now enable moderation state. + $this->enableModerationThroughUi( + 'not_moderated', + ['draft', 'needs_review', 'published'], + 'draft' + ); + + // And make sure it works. + $nodes = \Drupal::entityTypeManager()->getStorage('node') + ->loadByProperties(['title' => 'Test']); + if (empty($nodes)) { + $this->fail('Could not load node with title Test'); + return; + } + $node = reset($nodes); + $this->drupalGet('node/' . $node->id()); + $this->assertResponse(200); + $this->assertLinkByHref('node/' . $node->id() . '/edit'); + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertResponse(200); + $this->assertRaw('Save and Create New Draft'); + $this->assertNoRaw('Save and publish'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php new file mode 100644 index 0000000..6ec8d4e --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php @@ -0,0 +1,75 @@ +drupalGet($path); + // No access. + $this->assertResponse(403); + } + $this->drupalLogin($this->adminUser); + foreach ($paths as $path) { + $this->drupalGet($path); + // User has access. + $this->assertResponse(200); + } + } + + /** + * Tests administration of moderation state entity. + */ + public function testStateAdministration() { + $this->drupalLogin($this->adminUser); + $this->drupalGet('admin/config/workflow/moderation'); + $this->assertLink('Moderation states'); + $this->assertLink('Moderation state transitions'); + $this->clickLink('Moderation states'); + $this->assertLink('Add Moderation state'); + $this->assertText('Draft'); + // Edit the draft. + $this->clickLink('Edit', 1); + $this->assertFieldByName('label', 'Draft'); + $this->assertNoFieldChecked('edit-published'); + $this->drupalPostForm(NULL, [ + 'label' => 'Drafty', + ], t('Save')); + $this->assertText('Saved the Drafty Moderation state.'); + $this->drupalGet('admin/config/workflow/moderation/states/draft'); + $this->assertFieldByName('label', 'Drafty'); + $this->drupalPostForm(NULL, [ + 'label' => 'Draft', + ], t('Save')); + $this->assertText('Saved the Draft Moderation state.'); + $this->clickLink(t('Add Moderation state')); + $this->drupalPostForm(NULL, [ + 'label' => 'Expired', + 'id' => 'expired', + ], t('Save')); + $this->assertText('Created the Expired Moderation state.'); + $this->drupalGet('admin/config/workflow/moderation/states/expired'); + $this->clickLink('Delete'); + $this->assertText('Are you sure you want to delete Expired?'); + $this->drupalPostForm(NULL, [], t('Delete')); + $this->assertText('Moderation state Expired deleted'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php new file mode 100644 index 0000000..f03de12 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php @@ -0,0 +1,145 @@ +adminUser = $this->drupalCreateUser($this->permissions); + $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']); + $this->drupalPlaceBlock('page_title_block'); + $this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']); + } + + /** + * Creates a content-type from the UI. + * + * @param string $content_type_name + * Content type human name. + * @param string $content_type_id + * Machine name. + * @param bool $moderated + * TRUE if should be moderated. + * @param string[] $allowed_states + * Array of allowed state IDs. + * @param string $default_state + * Default state. + */ + protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) { + $this->drupalGet('admin/structure/types'); + $this->clickLink('Add content type'); + $edit = [ + 'name' => $content_type_name, + 'type' => $content_type_id, + ]; + $this->drupalPostForm(NULL, $edit, t('Save content type')); + + if ($moderated) { + $this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state); + } + } + + /** + * Enable moderation for a specified content type, using the UI. + * + * @param string $content_type_id + * Machine name. + * @param string[] $allowed_states + * Array of allowed state IDs. + * @param string $default_state + * Default state. + */ + protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) { + $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation'); + $this->assertFieldByName('enable_moderation_state'); + $this->assertNoFieldChecked('edit-enable-moderation-state'); + + $edit['enable_moderation_state'] = 1; + + /** @var ModerationState $state */ + foreach (ModerationState::loadMultiple() as $state) { + $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']'; + $edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE; + } + + $edit['default_moderation_state'] = $default_state; + + $this->drupalPostForm(NULL, $edit, t('Save')); + } + + /** + * Grants given user permission to create content of given type. + * + * @param \Drupal\Core\Session\AccountInterface $account + * User to grant permission to. + * @param string $content_type_id + * Content type ID. + */ + protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) { + $role_ids = $account->getRoles(TRUE); + /* @var \Drupal\user\RoleInterface $role */ + $role_id = reset($role_ids); + $role = Role::load($role_id); + $role->grantPermission(sprintf('create %s content', $content_type_id)); + $role->grantPermission(sprintf('edit any %s content', $content_type_id)); + $role->grantPermission(sprintf('delete any %s content', $content_type_id)); + $role->save(); + } + +} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php new file mode 100644 index 0000000..0495e48 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php @@ -0,0 +1,91 @@ +drupalGet($path); + // No access. + $this->assertResponse(403); + } + $this->drupalLogin($this->adminUser); + foreach ($paths as $path) { + $this->drupalGet($path); + // User has access. + $this->assertResponse(200); + } + } + + /** + * Tests administration of moderation state transition entity. + */ + public function testTransitionAdministration() { + $this->drupalLogin($this->adminUser); + + $this->drupalGet('admin/config/workflow/moderation'); + $this->clickLink('Moderation state transitions'); + $this->assertLink('Add Moderation state transition'); + $this->assertText('Create New Draft'); + + // Edit the Draft » Draft review. + $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft'); + $this->assertFieldByName('label', 'Create New Draft'); + $this->assertFieldByName('stateFrom', 'draft'); + $this->assertFieldByName('stateTo', 'draft'); + $this->drupalPostForm(NULL, [ + 'label' => 'Create Draft', + ], t('Save')); + $this->assertText('Saved the Create Draft Moderation state transition.'); + $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft'); + $this->assertFieldByName('label', 'Create Draft'); + // Now set it back. + $this->drupalPostForm(NULL, [ + 'label' => 'Create New Draft', + ], t('Save')); + $this->assertText('Saved the Create New Draft Moderation state transition.'); + + // Add a new state. + $this->drupalGet('admin/config/workflow/moderation/states/add'); + $this->drupalPostForm(NULL, [ + 'label' => 'Expired', + 'id' => 'expired', + ], t('Save')); + $this->assertText('Created the Expired Moderation state.'); + + // Add a new transition. + $this->drupalGet('admin/config/workflow/moderation/transitions'); + $this->clickLink(t('Add Moderation state transition')); + $this->drupalPostForm(NULL, [ + 'label' => 'Published » Expired', + 'id' => 'published_expired', + 'stateFrom' => 'published', + 'stateTo' => 'expired', + ], t('Save')); + $this->assertText('Created the Published » Expired Moderation state transition.'); + + // Delete the new transition. + $this->drupalGet('admin/config/workflow/moderation/transitions/published_expired'); + $this->clickLink('Delete'); + $this->assertText('Are you sure you want to delete Published » Expired?'); + $this->drupalPostForm(NULL, [], t('Delete')); + $this->assertText('Moderation transition Published » Expired deleted'); + } + +} diff --git a/core/modules/content_moderation/src/Tests/NodeAccessTest.php b/core/modules/content_moderation/src/Tests/NodeAccessTest.php new file mode 100644 index 0000000..1b05406 --- /dev/null +++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php @@ -0,0 +1,108 @@ +drupalLogin($this->adminUser); + $this->createContentTypeFromUi( + 'Moderated content', + 'moderated_content', + TRUE, + ['draft', 'published'], + 'draft' + ); + $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); + } + + /** + * Verifies that a non-admin user can still access the appropriate pages. + */ + public function testPageAccess() { + $this->drupalLogin($this->adminUser); + + // Create a node to test with. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'moderated content', + ], t('Save and Create New Draft')); + $nodes = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties([ + 'title' => 'moderated content', + ]); + + if (!$nodes) { + $this->fail('Test node was not saved correctly.'); + return; + } + + /** @var \Drupal\node\NodeInterface $node */ + $node = reset($nodes); + + $view_path = 'node/' . $node->id(); + $edit_path = 'node/' . $node->id() . '/edit'; + $latest_path = 'node/' . $node->id() . '/latest'; + + // Publish the node. + $this->drupalPostForm($edit_path, [], t('Save and Publish')); + + // Ensure access works correctly for anonymous users. + $this->drupalLogout(); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(403); + $this->drupalGet($view_path); + $this->assertResponse(200); + + // Create a forward revision for the 'Latest revision' tab. + $this->drupalLogin($this->adminUser); + $this->drupalPostForm($edit_path, [ + 'title[0][value]' => 'moderated content revised', + ], t('Save and Create New Draft')); + + // Now make a new user and verify that the new user's access is correct. + $user = $this->createUser([ + 'use draft_draft transition', + 'use published_draft transition', + 'view latest version', + 'view any unpublished content', + ]); + $this->drupalLogin($user); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(200); + $this->drupalGet($view_path); + $this->assertResponse(200); + + // Now make another user, who should not be able to see forward revisions. + $user = $this->createUser([ + 'use published_draft transition', + ]); + $this->drupalLogin($user); + + $this->drupalGet($edit_path); + $this->assertResponse(403); + + $this->drupalGet($latest_path); + $this->assertResponse(403); + $this->drupalGet($view_path); + $this->assertResponse(200); + } + +} diff --git a/core/modules/content_moderation/src/ViewsData.php b/core/modules/content_moderation/src/ViewsData.php new file mode 100644 index 0000000..cad1187 --- /dev/null +++ b/core/modules/content_moderation/src/ViewsData.php @@ -0,0 +1,260 @@ +entityTypeManager = $entity_type_manager; + $this->moderationInformation = $moderation_information; + } + + /** + * Returns the views data. + * + * @return array + * The views data. + */ + public function getViewsData() { + $data = []; + + $data['content_revision_tracker']['table']['group'] = $this->t('Content moderation (tracker)'); + + $data['content_revision_tracker']['entity_type'] = [ + 'title' => $this->t('Entity type'), + 'field' => [ + 'id' => 'standard', + ], + 'filter' => [ + 'id' => 'string', + ], + 'argument' => [ + 'id' => 'string', + ], + 'sort' => [ + 'id' => 'standard', + ], + ]; + + $data['content_revision_tracker']['entity_id'] = [ + 'title' => $this->t('Entity ID'), + 'field' => [ + 'id' => 'standard', + ], + 'filter' => [ + 'id' => 'numeric', + ], + 'argument' => [ + 'id' => 'numeric', + ], + 'sort' => [ + 'id' => 'standard', + ], + ]; + + $data['content_revision_tracker']['langcode'] = [ + 'title' => $this->t('Entity language'), + 'field' => [ + 'id' => 'standard', + ], + 'filter' => [ + 'id' => 'language', + ], + 'argument' => [ + 'id' => 'language', + ], + 'sort' => [ + 'id' => 'standard', + ], + ]; + + $data['content_revision_tracker']['revision_id'] = [ + 'title' => $this->t('Latest revision ID'), + 'field' => [ + 'id' => 'standard', + ], + 'filter' => [ + 'id' => 'numeric', + ], + 'argument' => [ + 'id' => 'numeric', + ], + 'sort' => [ + 'id' => 'standard', + ], + ]; + + // Add a join for each entity type to the content_revision_tracker table. + foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) { + /** @var \Drupal\views\EntityViewsDataInterface $views_data */ + // We need the views_data handler in order to get the table name later. + if ($this->entityTypeManager->hasHandler($entity_type_id, 'views_data') && $views_data = $this->entityTypeManager->getHandler($entity_type_id, 'views_data')) { + // Add a join from the entity base table to the revision tracker table. + $base_table = $views_data->getViewsTableForEntityType($entity_type); + $data['content_revision_tracker']['table']['join'][$base_table] = [ + 'left_field' => $entity_type->getKey('id'), + 'field' => 'entity_id', + 'extra' => [ + [ + 'field' => 'entity_type', + 'value' => $entity_type_id, + ], + ], + ]; + + // Some entity types might not be translatable. + if ($entity_type->hasKey('langcode')) { + $data['content_revision_tracker']['table']['join'][$base_table]['extra'][] = [ + 'field' => 'langcode', + 'left_field' => $entity_type->getKey('langcode'), + 'operation' => '=', + ]; + } + + // Add a relationship between the revision tracker table to the latest + // revision on the entity revision table. + $data['content_revision_tracker']['latest_revision__' . $entity_type_id] = [ + 'title' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]), + 'group' => $this->t('@label revision', ['@label' => $entity_type->getLabel()]), + 'relationship' => [ + 'id' => 'standard', + 'label' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]), + 'base' => $this->getRevisionViewsTableForEntityType($entity_type), + 'base field' => $entity_type->getKey('revision'), + 'relationship field' => 'revision_id', + 'extra' => [ + [ + 'left_field' => 'entity_type', + 'value' => $entity_type_id, + ], + ], + ], + ]; + + // Some entity types might not be translatable. + if ($entity_type->hasKey('langcode')) { + $data['content_revision_tracker']['latest_revision__' . $entity_type_id]['relationship']['extra'][] = [ + 'left_field' => 'langcode', + 'field' => $entity_type->getKey('langcode'), + 'operation' => '=', + ]; + } + } + } + + // Provides a relationship from moderated entity to its moderation state + // entity. + $content_moderation_state_entity_type = \Drupal::entityTypeManager()->getDefinition('content_moderation_state'); + $content_moderation_state_entity_base_table = $content_moderation_state_entity_type->getDataTable() ?: $content_moderation_state_entity_type->getBaseTable(); + $content_moderation_state_entity_revision_base_table = $content_moderation_state_entity_type->getRevisionDataTable() ?: $content_moderation_state_entity_type->getRevisionTable(); + foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) { + $table = $entity_type->getDataTable() ?: $entity_type->getBaseTable(); + + $data[$table]['moderation_state'] = [ + 'title' => t('Moderation state'), + 'relationship' => [ + 'id' => 'standard', + 'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]), + 'base' => $content_moderation_state_entity_base_table, + 'base field' => 'content_entity_id', + 'relationship field' => $entity_type->getKey('id'), + 'join_extra' => [ + [ + 'field' => 'content_entity_type_id', + 'value' => $entity_type_id, + ], + [ + 'field' => 'content_entity_revision_id', + 'left_field' => $entity_type->getKey('revision'), + ], + ], + ], + ]; + + $revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable(); + $data[$revision_table]['moderation_state'] = [ + 'title' => t('Moderation state'), + 'relationship' => [ + 'id' => 'standard', + 'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]), + 'base' => $content_moderation_state_entity_revision_base_table, + 'base field' => 'content_entity_revision_id', + 'relationship field' => $entity_type->getKey('revision'), + 'join_extra' => [ + [ + 'field' => 'content_entity_type_id', + 'value' => $entity_type_id, + ], + ], + ], + ]; + } + + return $data; + } + + /** + * Alters the table and field information from hook_views_data(). + * + * @param array $data + * An array of all information about Views tables and fields, collected from + * hook_views_data(), passed by reference. + * + * @see hook_views_data() + */ + public function alterViewsData(array &$data) { + $revisionable_types = $this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()); + foreach ($revisionable_types as $type) { + $data[$type->getRevisionTable()]['latest_revision'] = [ + 'title' => t('Is Latest Revision'), + 'help' => t('Restrict the view to only revisions that are the latest revision of their entity.'), + 'filter' => ['id' => 'latest_revision'], + ]; + } + } + + /** + * Gets the table of an entity type to be used as revision table in views. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return string + * The revision base table. + */ + protected function getRevisionViewsTableForEntityType(EntityTypeInterface $entity_type) { + return $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable(); + } + +} diff --git a/core/modules/content_moderation/templates/entity-moderation-form.html.twig b/core/modules/content_moderation/templates/entity-moderation-form.html.twig new file mode 100644 index 0000000..403f5f0 --- /dev/null +++ b/core/modules/content_moderation/templates/entity-moderation-form.html.twig @@ -0,0 +1,8 @@ +{{ attach_library('content_moderation/entity-moderation-form') }} + +{{ form|without('current', 'new_state', 'revision_log', 'submit') }} diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml new file mode 100644 index 0000000..46a64ab --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml @@ -0,0 +1,409 @@ +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: entity_reference_label + settings: + link: true + 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: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml new file mode 100644 index 0000000..6f95251 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml @@ -0,0 +1,406 @@ +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: 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: 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: 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: 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: entity_reference_entity_id + settings: { } + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: content_moderation_state + entity_field: moderation_state + plugin_id: field + filters: { } + sorts: + 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: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml new file mode 100644 index 0000000..7673394 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml @@ -0,0 +1,447 @@ +langcode: en +status: true +dependencies: + module: + - node + - user +id: test_content_moderation_latest_revision +label: test_content_moderation_latest_revision +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: node + entity_field: nid + plugin_id: field + revision_id: + id: revision_id + table: content_revision_tracker + field: revision_id + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + plugin_id: standard + title: + id: title + table: node_field_revision + field: title + relationship: latest_revision__node + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: false + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: node + entity_field: title + plugin_id: field + moderation_state: + id: moderation_state + table: content_moderation_state_field_revision + field: moderation_state + relationship: moderation_state + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_entity_id + settings: { } + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: content_moderation_state + entity_field: moderation_state + plugin_id: field + moderation_state_1: + id: moderation_state_1 + table: content_moderation_state_field_revision + field: moderation_state + relationship: moderation_state_1 + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_entity_id + settings: { } + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: content_moderation_state + entity_field: moderation_state + plugin_id: field + filters: { } + sorts: + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: node + entity_field: nid + plugin_id: standard + header: { } + footer: { } + empty: { } + relationships: + latest_revision__node: + id: latest_revision__node + table: content_revision_tracker + field: latest_revision__node + relationship: none + group_type: group + admin_label: 'Content latest revision' + required: false + plugin_id: standard + moderation_state_1: + id: moderation_state_1 + table: node_field_revision + field: moderation_state + relationship: latest_revision__node + group_type: group + admin_label: 'Content moderation state (latest revision)' + required: false + entity_type: node + plugin_id: standard + moderation_state: + id: moderation_state + table: node_field_revision + field: moderation_state + relationship: none + group_type: group + admin_label: 'Content moderation state' + required: false + entity_type: node + plugin_id: standard + arguments: { } + display_extenders: { } + rendering_language: '***LANGUAGE_entity_default***' + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml new file mode 100644 index 0000000..2362098 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml @@ -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: entity_reference_entity_id + 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: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml new file mode 100644 index 0000000..44b68d4 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml @@ -0,0 +1,10 @@ +name: 'Content moderation test views' +type: module +description: 'Provides default views for views Content moderation tests.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - content_moderation + - node + - views \ No newline at end of file diff --git a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php new file mode 100644 index 0000000..77ae046 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php @@ -0,0 +1,128 @@ +createNodeType('Test', 'test'); + + $permissions = [ + 'access content', + 'view all revisions', + ]; + $editor1 = $this->drupalCreateUser($permissions); + + $this->drupalLogin($editor1); + + // Make a pre-moderation node. + /** @var Node $node_0 */ + $node_0 = Node::create([ + 'type' => 'test', + 'title' => 'Node 0 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_0->save(); + + // Now enable moderation for subsequent nodes. + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + + // Make a node that is only ever in Draft. + /** @var Node $node_1 */ + $node_1 = Node::create([ + 'type' => 'test', + 'title' => 'Node 1 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_1->moderation_state->target_id = 'draft'; + $node_1->save(); + + // Make a node that is in Draft, then Published. + /** @var Node $node_2 */ + $node_2 = Node::create([ + 'type' => 'test', + 'title' => 'Node 2 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_2->moderation_state->target_id = 'draft'; + $node_2->save(); + + $node_2->setTitle('Node 2 - Rev 2'); + $node_2->moderation_state->target_id = 'published'; + $node_2->save(); + + // Make a node that is in Draft, then Published, then Draft. + /** @var Node $node_3 */ + $node_3 = Node::create([ + 'type' => 'test', + 'title' => 'Node 3 - Rev 1', + 'uid' => $editor1->id(), + ]); + $node_3->moderation_state->target_id = 'draft'; + $node_3->save(); + + $node_3->setTitle('Node 3 - Rev 2'); + $node_3->moderation_state->target_id = 'published'; + $node_3->save(); + + $node_3->setTitle('Node 3 - Rev 3'); + $node_3->moderation_state->target_id = 'draft'; + $node_3->save(); + + // Now show the View, and confirm that only the correct titles are showing. + $this->drupalGet('/latest'); + $page = $this->getSession()->getPage(); + $this->assertEquals(200, $this->getSession()->getStatusCode()); + $this->assertTrue($page->hasContent('Node 1 - Rev 1')); + $this->assertTrue($page->hasContent('Node 2 - Rev 2')); + $this->assertTrue($page->hasContent('Node 3 - Rev 3')); + $this->assertFalse($page->hasContent('Node 2 - Rev 1')); + $this->assertFalse($page->hasContent('Node 3 - Rev 1')); + $this->assertFalse($page->hasContent('Node 3 - Rev 2')); + $this->assertFalse($page->hasContent('Node 0 - Rev 1')); + } + + /** + * Creates a new node type. + * + * @param string $label + * The human-readable label of the type to create. + * @param string $machine_name + * The machine name of the type to create. + * + * @return NodeType + * The node type just created. + */ + protected function createNodeType($label, $machine_name) { + /** @var NodeType $node_type */ + $node_type = NodeType::create([ + 'type' => $machine_name, + 'label' => $label, + ]); + $node_type->save(); + + return $node_type; + } + +} diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php new file mode 100644 index 0000000..28ee969 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php @@ -0,0 +1,107 @@ +createNodeType('Test', $node_type_id); + + $permissions = [ + 'access content', + 'view all revisions', + 'view moderation states', + ]; + $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->target_id = 'draft'; + $node_1->save(); + + $node_2 = Node::create([ + 'type' => $node_type_id, + 'title' => 'Published node', + 'uid' => $editor1->id(), + ]); + $node_2->moderation_state->target_id = 'published'; + $node_2->save(); + + // Resave the node with a new state. + $node_2->setTitle('Archived node'); + $node_2->moderation_state->target_id = '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->hasLink('Draft')); + $this->assertTrue($page->hasLink('Archived')); + $this->assertFalse($page->hasLink('Published')); + + // Now log in as an admin and test the same thing. + $permissions = [ + 'access content', + 'view all revisions', + 'administer moderation states', + ]; + $admin1 = $this->drupalCreateUser($permissions); + $this->drupalLogin($admin1); + + $this->drupalGet('/latest'); + $page = $this->getSession()->getPage(); + $this->assertEquals(200, $this->getSession()->getStatusCode()); + $this->assertTrue($page->hasLink('Draft')); + $this->assertTrue($page->hasLink('Archived')); + $this->assertFalse($page->hasLink('Published')); + } + + /** + * Creates a new node type. + * + * @param string $label + * The human-readable label of the type to create. + * @param string $machine_name + * The machine name of the type to create. + * + * @return NodeType + * The node type just created. + */ + protected function createNodeType($label, $machine_name) { + /** @var NodeType $node_type */ + $node_type = NodeType::create([ + 'type' => $machine_name, + 'label' => $label, + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + + return $node_type; + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php new file mode 100644 index 0000000..8b382c1 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php @@ -0,0 +1,89 @@ +installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + foreach ($moderation_states as $moderation_state) { + $this->assertConfigSchema($typed_config, $moderation_state->getEntityType()->getConfigPrefix() . '.' . $moderation_state->id(), $moderation_state->toArray()); + } + $moderation_state_transitions = ModerationStateTransition::loadMultiple(); + foreach ($moderation_state_transitions as $moderation_state_transition) { + $this->assertConfigSchema($typed_config, $moderation_state_transition->getEntityType()->getConfigPrefix() . '.' . $moderation_state_transition->id(), $moderation_state_transition->toArray()); + } + + } + + /** + * Tests content moderation third party schema for node types. + */ + public function testContentModerationNodeTypeConfig() { + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); + $node_type->save(); + $this->assertConfigSchema($typed_config, $node_type->getEntityType()->getConfigPrefix() . '.' . $node_type->id(), $node_type->toArray()); + } + + /** + * Tests content moderation third party schema for block content types. + */ + public function testContentModerationBlockContentTypeConfig() { + $this->installEntitySchema('block_content'); + $this->installEntitySchema('user'); + $this->installConfig(['content_moderation']); + $typed_config = \Drupal::service('config.typed'); + $moderation_states = ModerationState::loadMultiple(); + $block_content_type = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'basic', + 'revision' => TRUE, + ]); + $block_content_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $block_content_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); + $block_content_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); + $block_content_type->save(); + $this->assertConfigSchema($typed_config, $block_content_type->getEntityType()->getConfigPrefix() . '.' . $block_content_type->id(), $block_content_type->toArray()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php new file mode 100644 index 0000000..c76e651 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php @@ -0,0 +1,234 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installConfig('content_moderation'); + } + + /** + * Tests basic monolingual content moderation through the API. + */ + public function testBasicModeration() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->save(); + $node = $this->reloadNode($node); + $this->assertEquals('draft', $node->moderation_state->entity->id()); + + $published = ModerationState::load('published'); + $node->moderation_state->entity = $published; + $node->save(); + + $node = $this->reloadNode($node); + $this->assertEquals('published', $node->moderation_state->entity->id()); + + // 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(); + + $node = $this->reloadNode($node, 3); + $this->assertEquals('draft', $node->moderation_state->entity->id()); + $this->assertFalse($node->isPublished()); + + // Get the default revision. + $node = $this->reloadNode($node); + $this->assertTrue($node->isPublished()); + $this->assertEquals(2, $node->getRevisionId()); + + $node->moderation_state->target_id = 'published'; + $node->save(); + + $node = $this->reloadNode($node, 4); + $this->assertEquals('published', $node->moderation_state->entity->id()); + + // Get the default revision. + $node = $this->reloadNode($node); + $this->assertTrue($node->isPublished()); + $this->assertEquals(4, $node->getRevisionId()); + + } + + /** + * 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->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->save(); + $english_node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + // Revision 1 (en). + $english_node + ->setPublished(FALSE) + ->save(); + $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertFalse($english_node->isPublished()); + + // Create a French translation. + $french_node = $english_node->addTranslation('fr', ['title' => 'French title']); + $french_node->setPublished(FALSE); + // Revision 1 (fr). + $french_node->save(); + $french_node = $this->reloadNode($english_node)->getTranslation('fr'); + $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + $this->assertFalse($french_node->isPublished()); + + // Move English node to create another draft. + $english_node = $this->reloadNode($english_node); + $english_node->moderation_state->target_id = 'draft'; + // Revision 2 (en, fr). + $english_node->save(); + $english_node = $this->reloadNode($english_node); + $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + + // French node should still be in draft. + $french_node = $this->reloadNode($english_node)->getTranslation('fr'); + $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + + // Publish the French node. + $french_node->moderation_state->target_id = 'published'; + // Revision 3 (en, fr). + $french_node->save(); + $french_node = $this->reloadNode($french_node)->getTranslation('fr'); + $this->assertTrue($french_node->isPublished()); + $this->assertEquals('published', $french_node->moderation_state->entity->id()); + $this->assertTrue($french_node->isPublished()); + $english_node = $french_node->getTranslation('en'); + $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + + // Publish the English node. + $english_node->moderation_state->target_id = 'published'; + // Revision 4 (en, fr). + $english_node->save(); + $english_node = $this->reloadNode($english_node); + $this->assertTrue($english_node->isPublished()); + + // Move the French node back to draft. + $french_node = $this->reloadNode($english_node)->getTranslation('fr'); + $this->assertTrue($french_node->isPublished()); + $french_node->moderation_state->target_id = 'draft'; + // Revision 5 (en, fr). + $french_node->save(); + $french_node = $this->reloadNode($english_node, 5)->getTranslation('fr'); + $this->assertFalse($french_node->isPublished()); + $this->assertTrue($french_node->getTranslation('en')->isPublished()); + + // Republish the French node. + $french_node->moderation_state->target_id = 'published'; + // Revision 6 (en, fr). + $french_node->save(); + $french_node = $this->reloadNode($english_node)->getTranslation('fr'); + $this->assertTrue($french_node->isPublished()); + + // Change the EN state without saving the node. + $content_moderation_state = ContentModerationState::load(1); + $content_moderation_state->set('moderation_state', 'draft'); + $content_moderation_state->setNewRevision(TRUE); + // Revision 7 (en, fr). + $content_moderation_state->save(); + $english_node = $this->reloadNode($french_node, $french_node->getRevisionId() + 1); + + $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $french_node = $this->reloadNode($english_node)->getTranslation('fr'); + $this->assertEquals('published', $french_node->moderation_state->entity->id()); + + // This should unpublish the French node. + $content_moderation_state = ContentModerationState::load(1); + $content_moderation_state = $content_moderation_state->getTranslation('fr'); + $content_moderation_state->set('moderation_state', 'draft'); + $content_moderation_state->setNewRevision(TRUE); + // Revision 8 (en, fr). + $content_moderation_state->save(); + + $english_node = $this->reloadNode($english_node, $english_node->getRevisionId()); + $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $french_node = $this->reloadNode($english_node, '8')->getTranslation('fr'); + $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + // 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->reloadNode($english_node); + $this->assertTrue($english_node->isPublished()); + $this->assertEquals(6, $english_node->getRevisionId()); + } + + /** + * Reloads the node after clearing the static cache. + * + * @param \Drupal\node\NodeInterface $node + * The node to reload. + * @param int|FALSE $revision_id + * The specific revision ID to load. Defaults FALSE and just loads the + * default revision. + * + * @return \Drupal\node\NodeInterface + * The reloaded node. + */ + protected function reloadNode(NodeInterface $node, $revision_id = FALSE) { + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $storage->resetCache([$node->id()]); + if ($revision_id) { + return $storage->loadRevision($revision_id); + } + return $storage->load($node->id()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php new file mode 100644 index 0000000..99d8f0e --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php @@ -0,0 +1,197 @@ +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 moderatable. + */ + protected function createNodeType() { + $node_type = NodeType::create([ + 'type' => 'page', + 'label' => 'Page', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + } + + /** + * Verifies that the process of saving forward-revisions works as expected. + */ + public function testForwardRevisions() { + // Create a new node in draft. + $page = Node::create([ + 'type' => 'page', + 'title' => 'A', + ]); + $page->moderation_state->target_id = 'draft'; + $page->save(); + + $id = $page->id(); + + // Verify the entity saved correctly, and that the presence of forward + // revisions doesn't affect the default node load. + /** @var Node $page */ + $page = Node::load($id); + $this->assertEquals('A', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertFalse($page->isPublished()); + + // Moderate the entity to published. + $page->setTitle('B'); + $page->moderation_state->target_id = 'published'; + $page->save(); + + // Verify the entity is now published and public. + $page = Node::load($id); + $this->assertEquals('B', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + + // Make a new forward-revision in Draft. + $page->setTitle('C'); + $page->moderation_state->target_id = 'draft'; + $page->save(); + + // Verify normal loads return the still-default previous version. + $page = Node::load($id); + $this->assertEquals('B', $page->getTitle()); + + // Verify we can load the forward revision, even if the mechanism is kind + // of gross. Note: revisionIds() is only available on NodeStorageInterface, + // so this won't work for non-nodes. We'd need to use entity queries. This + // is a core bug that should get fixed. + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $revision_ids = $storage->revisionIds($page); + sort($revision_ids); + $latest = end($revision_ids); + $page = $storage->loadRevision($latest); + $this->assertEquals('C', $page->getTitle()); + + $page->setTitle('D'); + $page->moderation_state->target_id = '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->target_id = '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->target_id = 'published'; + $page->save(); + + $id = $page->id(); + + // Verify the entity saved correctly. + /** @var Node $page */ + $page = Node::load($id); + $this->assertEquals('A', $page->getTitle()); + $this->assertTrue($page->isDefaultRevision()); + $this->assertTrue($page->isPublished()); + } + + /** + * Verifies that an unpublished state may be made the default revision. + */ + public function testArchive() { + $published_id = $this->randomMachineName(); + $published_state = ModerationState::create([ + 'id' => $published_id, + 'label' => $this->randomString(), + 'published' => TRUE, + 'default_revision' => TRUE, + ]); + $published_state->save(); + + $archived_id = $this->randomMachineName(); + $archived_state = ModerationState::create([ + 'id' => $archived_id, + 'label' => $this->randomString(), + 'published' => FALSE, + 'default_revision' => TRUE, + ]); + $archived_state->save(); + + $page = Node::create([ + 'type' => 'page', + 'title' => $this->randomString(), + ]); + $page->moderation_state->target_id = $published_id; + $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->target_id = $archived_id; + $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()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php new file mode 100644 index 0000000..89c84f9 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php @@ -0,0 +1,95 @@ +installEntitySchema('entity_test'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installSchema('system', 'router'); + $this->installSchema('system', 'sequences'); + $this->installSchema('node', 'node_access'); + \Drupal::service('router.builder')->rebuild(); + } + + /** + * @covers ::convert + */ + public function testConvertNonRevisionableEntityType() { + $entity_test = EntityTest::create([ + 'name' => 'test', + ]); + + $entity_test->save(); + + /** @var \Symfony\Component\Routing\RouterInterface $router */ + $router = \Drupal::service('router.no_access_checks'); + $result = $router->match('/entity_test/' . $entity_test->id()); + + $this->assertInstanceOf(EntityTest::class, $result['entity_test']); + $this->assertEquals($entity_test->getRevisionId(), $result['entity_test']->getRevisionId()); + } + + /** + * @covers ::convert + */ + public function testConvertWithRevisionableEntityType() { + $node_type = NodeType::create([ + 'type' => 'article', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + + $revision_ids = []; + $node = Node::create([ + 'title' => 'test', + 'type' => 'article', + ]); + $node->save(); + + $revision_ids[] = $node->getRevisionId(); + + $node->setNewRevision(TRUE); + $node->save(); + $revision_ids[] = $node->getRevisionId(); + + $node->setNewRevision(TRUE); + $node->isDefaultRevision(FALSE); + $node->save(); + $revision_ids[] = $node->getRevisionId(); + + /** @var \Symfony\Component\Routing\RouterInterface $router */ + $router = \Drupal::service('router.no_access_checks'); + $result = $router->match('/node/' . $node->id() . '/edit'); + + $this->assertInstanceOf(Node::class, $result['node']); + $this->assertEquals($revision_ids[2], $result['node']->getRevisionId()); + $this->assertFalse($result['node']->isDefaultRevision()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php new file mode 100644 index 0000000..97e61f1 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php @@ -0,0 +1,174 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installConfig('content_moderation'); + } + + /** + * Test valid transitions. + * + * @covers ::validate + */ + public function testValidTransition() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->moderation_state->target_id = 'draft'; + $node->save(); + + $node->moderation_state->target_id = 'published'; + $this->assertCount(0, $node->validate()); + $node->save(); + + $this->assertEquals('published', $node->moderation_state->entity->id()); + } + + /** + * Test invalid transitions. + * + * @covers ::validate + */ + public function testInvalidTransition() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->moderation_state->target_id = 'draft'; + $node->save(); + + $node->moderation_state->target_id = 'archived'; + $violations = $node->validate(); + $this->assertCount(1, $violations); + + $this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage()); + } + + /** + * Tests that content without prior moderation information can be moderated. + */ + public function testLegacyContent() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + /** @var \Drupal\node\NodeInterface $node */ + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->save(); + + $nid = $node->id(); + + // Enable moderation for our node type. + /** @var NodeType $node_type */ + $node_type = NodeType::load('example'); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->save(); + + $node = Node::load($nid); + + // Having no previous state should not break validation. + $violations = $node->validate(); + + $this->assertCount(0, $violations); + + // Having no previous state should not break saving the node. + $node->setTitle('New'); + $node->save(); + } + + /** + * Tests that content without prior moderation information can be translated. + */ + public function testLegacyMultilingualContent() { + // Enable French. + ConfigurableLanguage::createFromLangcode('fr')->save(); + + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + /** @var \Drupal\node\NodeInterface $node */ + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'langcode' => 'en', + ]); + $node->save(); + + $nid = $node->id(); + + $node = Node::load($nid); + + // Creating a translation shouldn't break, even though there's no previous + // moderated revision for the new language. + $node_fr = $node->addTranslation('fr'); + $node_fr->setTitle('Francais'); + $node_fr->save(); + + // Enable moderation for our node type. + /** @var NodeType $node_type */ + $node_type = NodeType::load('example'); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); + $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); + $node_type->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(); + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php new file mode 100644 index 0000000..f312cde --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php @@ -0,0 +1,69 @@ +installEntitySchema('moderation_state'); + } + + /** + * Verify moderation state methods based on entity properties. + * + * @covers ::isPublishedState + * @covers ::isDefaultRevisionState + * + * @dataProvider moderationStateProvider + */ + public function testModerationStateProperties($published, $default_revision, $is_published, $is_default) { + $moderation_state_id = $this->randomMachineName(); + $moderation_state = ModerationState::create([ + 'id' => $moderation_state_id, + 'label' => $this->randomString(), + 'published' => $published, + 'default_revision' => $default_revision, + ]); + $moderation_state->save(); + + $moderation_state = ModerationState::load($moderation_state_id); + $this->assertEquals($is_published, $moderation_state->isPublishedState()); + $this->assertEquals($is_default, $moderation_state->isDefaultRevisionState()); + } + + /** + * Data provider for ::testModerationStateProperties. + */ + public function moderationStateProvider() { + return [ + // Draft, Needs review; should not touch the default revision. + [FALSE, FALSE, FALSE, FALSE], + // Published; this state should update and publish the default revision. + [TRUE, TRUE, TRUE, TRUE], + // Archive; this state should update but not publish the default revision. + [FALSE, TRUE, FALSE, TRUE], + // We try to prevent creating this state via the UI, but when a moderation + // state is a published state, it should also become the default revision. + [TRUE, FALSE, TRUE, TRUE], + ]; + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php new file mode 100644 index 0000000..c869619 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php @@ -0,0 +1,159 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installSchema('node', 'node_access'); + $this->installConfig('content_moderation_test_views'); + $this->installConfig('content_moderation'); + + $node_type = NodeType::create([ + 'type' => 'page', + ]); + $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); + $node_type->save(); + } + + /** + * Tests content_moderation_views_data(). + * + * @see content_moderation_views_data() + */ + public function testViewsData() { + $node = Node::create([ + 'type' => 'page', + 'title' => 'Test title first revision', + ]); + $node->moderation_state->target_id = 'published'; + $node->save(); + + $revision = clone $node; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->title->value = 'Test title second revision'; + $revision->moderation_state->target_id = 'draft'; + $revision->save(); + + $view = Views::getView('test_content_moderation_latest_revision'); + $view->execute(); + + // Ensure that the content_revision_tracker contains the right latest + // revision ID. + // Also ensure that the relationship back to the revision table contains the + // right latest revision. + $expected_result = [ + [ + 'nid' => $node->id(), + 'revision_id' => $revision->getRevisionId(), + 'title' => $revision->label(), + 'moderation_state_1' => 'draft', + 'moderation_state' => 'published', + ], + ]; + $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'content_revision_tracker_revision_id' => 'revision_id', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1']); + } + + /** + * Tests the join from the revision data table to the moderation state table. + */ + public function testContentModerationStateRevisionJoin() { + $node = Node::create([ + 'type' => 'page', + 'title' => 'Test title first revision', + ]); + $node->moderation_state->target_id = 'published'; + $node->save(); + + $revision = clone $node; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->title->value = 'Test title second revision'; + $revision->moderation_state->target_id = 'draft'; + $revision->save(); + + $view = Views::getView('test_content_moderation_revision_test'); + $view->execute(); + + $expected_result = [ + [ + 'revision_id' => $node->getRevisionId(), + 'moderation_state' => 'published', + ], + [ + 'revision_id' => $revision->getRevisionId(), + 'moderation_state' => 'draft', + ], + ]; + $this->assertIdenticalResultset($view, $expected_result, ['revision_id' => 'revision_id', 'moderation_state' => 'moderation_state']); + } + + /** + * Tests the join from the data table to the moderation state table. + */ + public function testContentModerationStateBaseJoin() { + $node = Node::create([ + 'type' => 'page', + 'title' => 'Test title first revision', + ]); + $node->moderation_state->target_id = 'published'; + $node->save(); + + $revision = clone $node; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->title->value = 'Test title second revision'; + $revision->moderation_state->target_id = 'draft'; + $revision->save(); + + $view = Views::getView('test_content_moderation_base_table_test'); + $view->execute(); + + $expected_result = [ + [ + 'nid' => $node->id(), + // @todo I would have expected that the content_moderation_state default + // revision is the same one as in the node, but it isn't. + // Joins from the base table to the default revision of the + // content_moderation. + 'moderation_state' => 'draft', + // Joins from the revision table to the default revision of the + // content_moderation. + 'moderation_state_1' => 'draft', + // Joins from the revision table to the revision of the + // content_moderation. + 'moderation_state_2' => 'published', + ], + ]; + $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1', 'moderation_state_2' => 'moderation_state_2']); + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php new file mode 100644 index 0000000..d026dd5 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php @@ -0,0 +1,72 @@ +setupCurrentRouteMatch($route_name, $route_nid)); + $node = $this->setupNode($check_nid); + $this->assertEquals($result, $content_preprocess->isLatestVersionPage($node), $message); + } + + /** + * Data provider for self::testIsLatestVersionPage(). + */ + public function routeNodeProvider() { + return [ + ['entity.node.cannonical', 1, 1, FALSE, 'Not on the latest version tab route.'], + ['entity.node.latest_version', 1, 1, TRUE, 'On the latest version tab route, with the route node.'], + ['entity.node.latest_version', 1, 2, FALSE, 'On the latest version tab route, with a different node.'], + ]; + } + + /** + * Mock the current route matching object. + * + * @param string $route_name + * The route to mock. + * @param int $nid + * The node ID for mocking. + * + * @return \Drupal\Core\Routing\CurrentRouteMatch + * The mocked current route match object. + */ + protected function setupCurrentRouteMatch($route_name, $nid) { + $route_match = $this->prophesize(CurrentRouteMatch::class); + $route_match->getRouteName()->willReturn($route_name); + $route_match->getParameter('node')->willReturn($this->setupNode($nid)); + + return $route_match->reveal(); + } + + /** + * Mock a node object. + * + * @param int $nid + * The node ID to mock. + * + * @return \Drupal\node\Entity\Node + * The mocked node. + */ + protected function setupNode($nid) { + $node = $this->prophesize(Node::class); + $node->id()->willReturn($nid); + + return $node->reveal(); + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php new file mode 100644 index 0000000..1f8838b --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php @@ -0,0 +1,75 @@ +prophesize($entity_class); + $entity->getCacheContexts()->willReturn([]); + $entity->getCacheTags()->willReturn([]); + $entity->getCacheMaxAge()->willReturn(0); + + /** @var \Drupal\content_moderation\ModerationInformation $mod_info */ + $mod_info = $this->prophesize(ModerationInformation::class); + $mod_info->hasForwardRevision($entity->reveal())->willReturn($has_forward); + + $route = $this->prophesize(Route::class); + + $route->getOption('_content_moderation_entity_type')->willReturn($entity_type); + + $route_match = $this->prophesize(RouteMatch::class); + $route_match->getParameter($entity_type)->willReturn($entity->reveal()); + + $lrc = new LatestRevisionCheck($mod_info->reveal()); + + /** @var \Drupal\Core\Access\AccessResult $result */ + $result = $lrc->access($route->reveal(), $route_match->reveal()); + + $this->assertInstanceOf($result_class, $result); + + } + + /** + * Data provider for testLastAccessPermissions(). + */ + public function accessSituationProvider() { + return [ + [Node::class, 'node', TRUE, AccessResultAllowed::class], + [Node::class, 'node', FALSE, AccessResultForbidden::class], + [BlockContent::class, 'block_content', TRUE, AccessResultAllowed::class], + [BlockContent::class, 'block_content', FALSE, AccessResultForbidden::class], + ]; + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php new file mode 100644 index 0000000..6833cdb --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php @@ -0,0 +1,158 @@ +prophesize(AccountInterface::class)->reveal(); + } + + /** + * Returns a mock Entity Type Manager. + * + * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage + * Entity bundle storage. + * + * @return EntityTypeManagerInterface + * The mocked entity type manager. + */ + protected function getEntityTypeManager(EntityStorageInterface $entity_bundle_storage) { + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('entity_test_bundle')->willReturn($entity_bundle_storage); + return $entity_type_manager->reveal(); + } + + /** + * Sets up content moderation and entity manager mocking. + * + * @param bool $status + * TRUE if content_moderation should be enabled, FALSE if not. + * + * @return \Drupal\Core\Entity\EntityTypeManagerInterface + * The mocked entity type manager. + */ + public function setupModerationEntityManager($status) { + $bundle = $this->prophesize(ConfigEntityInterface::class); + $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)->willReturn($status); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_storage->load('test_bundle')->willReturn($bundle->reveal()); + + return $this->getEntityTypeManager($entity_storage->reveal()); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratableEntity + */ + public function testIsModeratableEntity($status) { + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_bundle'); + + $this->assertEquals($status, $moderation_information->isModeratableEntity($entity->reveal())); + } + + /** + * @covers ::isModeratableEntity + */ + public function testIsModeratableEntityForNonBundleEntityType() { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + ]); + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_entity_type'); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_type_manager = $this->getEntityTypeManager($entity_storage->reveal()); + $moderation_information = new ModerationInformation($entity_type_manager, $this->getUser()); + + $this->assertEquals(FALSE, $moderation_information->isModeratableEntity($entity->reveal())); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratableBundle + */ + public function testIsModeratableBundle($status) { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $this->assertEquals($status, $moderation_information->isModeratableBundle($entity_type, 'test_bundle')); + } + + /** + * @dataProvider providerBoolean + * @covers ::isModeratedEntityForm + */ + public function testIsModeratedEntityForm($status) { + $entity_type = new ContentEntityType([ + 'id' => 'test_entity_type', + 'bundle_entity_type' => 'entity_test_bundle', + ]); + + $entity = $this->prophesize(ContentEntityInterface::class); + $entity->getEntityType()->willReturn($entity_type); + $entity->bundle()->willReturn('test_bundle'); + + $form = $this->prophesize(ContentEntityFormInterface::class); + $form->getEntity()->willReturn($entity); + + $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + + $this->assertEquals($status, $moderation_information->isModeratedEntityForm($form->reveal())); + } + + /** + * @covers ::isModeratedEntityForm + */ + public function testIsModeratedEntityFormWithNonContentEntityForm() { + $form = $this->prophesize(EntityFormInterface::class); + $moderation_information = new ModerationInformation($this->setupModerationEntityManager(TRUE), $this->getUser()); + + $this->assertFalse($moderation_information->isModeratedEntityForm($form->reveal())); + } + + /** + * Data provider for several tests. + */ + public function providerBoolean() { + return [ + [FALSE], + [TRUE], + ]; + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php new file mode 100644 index 0000000..b057478 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php @@ -0,0 +1,297 @@ +prophesize(EntityStorageInterface::class); + + $list = $this->setupTransitionEntityList(); + $entity_storage->loadMultiple()->willReturn($list); + $entity_storage->loadMultiple(Argument::type('array'))->will(function ($args) use ($list) { + $keys = $args[0]; + if (empty($keys)) { + return $list; + } + + $return = array_map(function($key) use ($list) { + return $list[$key]; + }, $keys); + + return $return; + }); + return $entity_storage->reveal(); + } + + /** + * Builds an array of mocked Transition objects. + * + * @return ModerationStateTransitionInterface[] + * An array of mocked Transition objects. + */ + protected function setupTransitionEntityList() { + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('draft__needs_review'); + $transition->getFromState()->willReturn('draft'); + $transition->getToState()->willReturn('needs_review'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__staging'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('staging'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('staging__published'); + $transition->getFromState()->willReturn('staging'); + $transition->getToState()->willReturn('published'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__draft'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('draft'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('draft__draft'); + $transition->getFromState()->willReturn('draft'); + $transition->getToState()->willReturn('draft'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('needs_review__needs_review'); + $transition->getFromState()->willReturn('needs_review'); + $transition->getToState()->willReturn('needs_review'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + $transition = $this->prophesize(ModerationStateTransitionInterface::class); + $transition->id()->willReturn('published__published'); + $transition->getFromState()->willReturn('published'); + $transition->getToState()->willReturn('published'); + $list[$transition->reveal()->id()] = $transition->reveal(); + + return $list; + } + + /** + * Builds a mock storage object for States. + * + * @return EntityStorageInterface + * The mocked storage object for States. + */ + protected function setupStateStorage() { + $entity_storage = $this->prophesize(EntityStorageInterface::class); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('draft'); + $state->label()->willReturn('Draft'); + $state->isPublishedState()->willReturn(FALSE); + $state->isDefaultRevisionState()->willReturn(FALSE); + $states['draft'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('needs_review'); + $state->label()->willReturn('Needs Review'); + $state->isPublishedState()->willReturn(FALSE); + $state->isDefaultRevisionState()->willReturn(FALSE); + $states['needs_review'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('staging'); + $state->label()->willReturn('Staging'); + $state->isPublishedState()->willReturn(FALSE); + $state->isDefaultRevisionState()->willReturn(FALSE); + $states['staging'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('published'); + $state->label()->willReturn('Published'); + $state->isPublishedState()->willReturn(TRUE); + $state->isDefaultRevisionState()->willReturn(TRUE); + $states['published'] = $state->reveal(); + + $state = $this->prophesize(ModerationStateInterface::class); + $state->id()->willReturn('archived'); + $state->label()->willReturn('Archived'); + $state->isPublishedState()->willReturn(TRUE); + $state->isDefaultRevisionState()->willReturn(TRUE); + $states['archived'] = $state->reveal(); + + $entity_storage->loadMultiple()->willReturn($states); + + foreach ($states as $id => $state) { + $entity_storage->load($id)->willReturn($state); + } + + return $entity_storage->reveal(); + } + + /** + * Builds a mocked Entity Type Manager. + * + * @return EntityTypeManagerInterface + * The mocked Entity Type Manager. + */ + protected function setupEntityTypeManager(EntityStorageInterface $storage) { + $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); + $entityTypeManager->getStorage('moderation_state')->willReturn($storage); + $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage()); + + return $entityTypeManager->reveal(); + } + + /** + * Builds a mocked query factory that does nothing. + * + * @return QueryFactory + * The mocked query factory that does nothing. + */ + protected function setupQueryFactory() { + $factory = $this->prophesize(QueryFactory::class); + + return $factory->reveal(); + } + + /** + * @covers ::isTransitionAllowed + * @covers ::calculatePossibleTransitions + * + * @dataProvider providerIsTransitionAllowedWithValidTransition + */ + public function testIsTransitionAllowedWithValidTransition($from_id, $to_id) { + $storage = $this->setupStateStorage(); + $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); + $this->assertTrue($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id))); + } + + /** + * Data provider for self::testIsTransitionAllowedWithValidTransition(). + */ + public function providerIsTransitionAllowedWithValidTransition() { + return [ + ['draft', 'draft'], + ['draft', 'needs_review'], + ['needs_review', 'needs_review'], + ['needs_review', 'staging'], + ['staging', 'published'], + ['needs_review', 'draft'], + ]; + } + + /** + * @covers ::isTransitionAllowed + * @covers ::calculatePossibleTransitions + * + * @dataProvider providerIsTransitionAllowedWithInValidTransition + */ + public function testIsTransitionAllowedWithInValidTransition($from_id, $to_id) { + $storage = $this->setupStateStorage(); + $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); + $this->assertFalse($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id))); + } + + /** + * Data provider for self::testIsTransitionAllowedWithInValidTransition(). + */ + public function providerIsTransitionAllowedWithInValidTransition() { + return [ + ['published', 'needs_review'], + ['published', 'staging'], + ['staging', 'needs_review'], + ['staging', 'staging'], + ['needs_review', 'published'], + ['published', 'archived'], + ['archived', 'published'], + ]; + } + + /** + * Verifies user-aware transition validation. + * + * @param string $from_id + * The state to transition from. + * @param string $to_id + * The state to transition to. + * @param string $permission + * The permission to give the user, or not. + * @param bool $allowed + * Whether or not to grant a user this permission. + * @param bool $result + * Whether userMayTransition() is expected to return TRUE or FALSE. + * + * @dataProvider userTransitionsProvider + */ + public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, $allowed, $result) { + $user = $this->prophesize(AccountInterface::class); + // The one listed permission will be returned as instructed; Any others are + // always denied. + $user->hasPermission($permission)->willReturn($allowed); + $user->hasPermission(Argument::type('string'))->willReturn(FALSE); + + $storage = $this->setupStateStorage(); + $validator = new Validator($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); + + $this->assertEquals($result, $validator->userMayTransition($storage->load($from_id), $storage->load($to_id), $user->reveal())); + } + + /** + * Data provider for the user transition test. + */ + public function userTransitionsProvider() { + // The user has the right permission, so let it through. + $ret[] = ['draft', 'draft', 'use draft__draft transition', TRUE, TRUE]; + + // The user doesn't have the right permission, block it. + $ret[] = ['draft', 'draft', 'use draft__draft transition', FALSE, FALSE]; + + // The user has some other permission that doesn't matter. + $ret[] = ['draft', 'draft', 'use draft__needs_review transition', TRUE, FALSE]; + + // The user has permission, but the transition isn't allowed anyway. + $ret[] = ['published', 'needs_review', 'use published__needs_review transition', TRUE, FALSE]; + + return $ret; + } + +} + +/** + * Testable subclass for selected tests. + * + * EntityQuery is beyond untestable, so we have to subclass and override the + * method that uses it. + */ +class Validator extends StateTransitionValidation { + + /** + * {@inheritdoc} + */ + protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) { + if ($from->id() === 'draft' && $to->id() === 'draft') { + return $this->transitionStorage()->loadMultiple(['draft__draft'])[0]; + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php index 5e2265d..f4e242d 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php @@ -24,6 +24,7 @@ class StableTemplateOverrideTest extends KernelTestBase { */ protected $templatesToSkip = [ 'views-form-views-form', + 'entity-moderation-form' ]; /**