Commit 4362606d authored by plach's avatar plach
Browse files

Issue #3037136 by amateescu, Sam152, blazey, Berdir: Make Workspaces and...

Issue #3037136 by amateescu, Sam152, blazey, Berdir: Make Workspaces and Content Moderation work together
parent c6c2ae2e
......@@ -5,21 +5,6 @@
* Install, update and uninstall functions for the Content Moderation module.
*/
/**
* Implements hook_requirements().
*/
function content_moderation_requirements($phase) {
$requirements = [];
if ($phase === 'install' && \Drupal::moduleHandler()->moduleExists('workspaces')) {
$requirements['workspaces_incompatibility'] = [
'severity' => REQUIREMENT_ERROR,
'description' => t('Content Moderation can not be installed when Workspaces is also installed.'),
];
}
return $requirements;
}
/**
* Remove the 'content_revision_tracker' table.
*/
......
......@@ -5,6 +5,7 @@
* Contains content_moderation.module.
*/
use Drupal\content_moderation\ContentModerationState;
use Drupal\content_moderation\EntityOperations;
use Drupal\content_moderation\EntityTypeInfo;
use Drupal\content_moderation\ContentPreprocess;
......@@ -29,6 +30,7 @@
use Drupal\Core\Action\Plugin\Action\PublishAction;
use Drupal\Core\Action\Plugin\Action\UnpublishAction;
use Drupal\workflows\Entity\Workflow;
use Drupal\workspaces\WorkspaceInterface;
use Drupal\views\Entity\View;
/**
......@@ -273,6 +275,65 @@ function content_moderation_entity_field_access($operation, FieldDefinitionInter
return AccessResult::neutral();
}
/**
* Implements hook_ENTITY_TYPE_access() for the 'workspace' entity type.
*
* Prevents a workspace to be published if there are any pending revisions in a
* moderation state that doesn't create default revisions.
*/
function content_moderation_workspace_access(WorkspaceInterface $workspace, $operation, AccountInterface $account) {
if ($operation !== 'publish') {
return AccessResult::neutral();
}
/** @var \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association */
$workspace_association = \Drupal::service('workspaces.association');
$entity_type_manager = \Drupal::entityTypeManager();
$tracked_revisions = $workspace_association->getTrackedEntities($workspace->id());
// Extract all the second-level keys (revision IDs) of the two-dimensional
// array.
$tracked_revision_ids = array_reduce(array_map('array_keys', $tracked_revisions), 'array_merge', []);
// Gather a list of moderation states that don't create a default revision.
$workflow_non_default_states = [];
foreach ($entity_type_manager->getStorage('workflow')->loadByProperties(['type' => 'content_moderation']) as $workflow) {
/** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $workflow_type */
$workflow_type = $workflow->getTypePlugin();
// Find all workflows which are moderating entity types of the same type to
// those that are tracked by the workspace.
if ($entity_type_ids = array_intersect($workflow_type->getEntityTypes(), array_keys($tracked_revisions))) {
$workflow_non_default_states[$workflow->id()] = array_filter(array_map(function (ContentModerationState $state) {
return !$state->isDefaultRevisionState() ? $state->id() : NULL;
}, $workflow_type->getStates()));
}
}
// Check if any revisions that are about to be published are in a non-default
// revision moderation state.
$query = $entity_type_manager->getStorage('content_moderation_state')->getQuery()
->allRevisions()
->accessCheck(FALSE);
$query->condition('content_entity_revision_id', $tracked_revision_ids, 'IN');
$workflow_condition_group = $query->orConditionGroup();
foreach ($workflow_non_default_states as $workflow_id => $non_default_states) {
$group = $query->andConditionGroup()
->condition('workflow', $workflow_id, '=')
->condition('moderation_state', $non_default_states, 'IN');
$workflow_condition_group->condition($group);
}
$query->condition($workflow_condition_group);
if ($count = $query->count()->execute()) {
$message = \Drupal::translation()->formatPlural($count, 'The @label workspace can not be published because it contains 1 item in an unpublished moderation state.', 'The @label workspace can not be published because it contains @count items in an unpublished moderation state.', [
'@label' => $workspace->label(),
]);
return AccessResult::forbidden((string) $message);
}
}
/**
* Implements hook_theme().
*/
......
......@@ -130,6 +130,9 @@ public function buildForm(array $form, FormStateInterface $form_state, ContentEn
$form['#theme'] = ['entity_moderation_form'];
$form['#attached']['library'][] = 'content_moderation/content_moderation';
// Moderating an entity is allowed in a workspace.
$form_state->set('workspace_safe', TRUE);
return $form;
}
......
<?php
namespace Drupal\Tests\content_moderation\Functional;
use Drupal\Tests\workspaces\Functional\WorkspaceTestUtilities;
use Drupal\workspaces\Entity\Workspace;
/**
* Tests Workspaces together with Content Moderation.
*
* @group content_moderation
* @group workspaces
*/
class WorkspaceContentModerationIntegrationTest extends ModerationStateTestBase {
use WorkspaceTestUtilities;
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'workspaces'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->rootUser);
// Enable moderation on Article node type.
$this->createContentTypeFromUi('Article', 'article', TRUE);
$this->setupWorkspaceSwitcherBlock();
}
/**
* Tests moderating nodes in a workspace.
*/
public function testModerationInWorkspace() {
$stage = Workspace::load('stage');
$this->switchToWorkspace($stage);
// Create two nodes, a published and a draft one.
$this->drupalPostForm('node/add/article', [
'title[0][value]' => 'First article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$this->drupalPostForm('node/add/article', [
'title[0][value]' => 'Second article - draft',
'moderation_state[0][state]' => 'draft',
], 'Save');
$first_article = $this->drupalGetNodeByTitle('First article - published', TRUE);
$this->assertEquals('published', $first_article->moderation_state->value);
$second_article = $this->drupalGetNodeByTitle('Second article - draft', TRUE);
$this->assertEquals('draft', $second_article->moderation_state->value);
// Check that neither of them are visible in Live.
$this->switchToLive();
$this->drupalGet('<front>');
$this->assertNoText('First article');
$this->assertNoText('Second article');
// Switch back to Stage.
$this->switchToWorkspace($stage);
// Take the first node through various moderation states.
$this->drupalGet('/node/1/edit');
$this->assertEquals('Current state Published', $this->cssSelect('#edit-moderation-state-0-current')[0]->getText());
$this->drupalPostForm(NULL, [
'title[0][value]' => 'First article - draft',
'moderation_state[0][state]' => 'draft',
], 'Save');
$this->drupalGet('/node/1');
$this->assertText('First article - draft');
$this->drupalGet('/node/1/edit');
$this->assertEquals('Current state Draft', $this->cssSelect('#edit-moderation-state-0-current')[0]->getText());
$this->drupalPostForm(NULL, [
'title[0][value]' => 'First article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$this->drupalPostForm('/node/1/edit', [
'title[0][value]' => 'First article - archived',
'moderation_state[0][state]' => 'archived',
], 'Save');
$this->drupalGet('/node/1');
$this->assertText('First article - archived');
// Get the second node to a default revision state and publish the
// workspace.
$this->drupalPostForm('/node/2/edit', [
'title[0][value]' => 'Second article - published',
'moderation_state[0][state]' => 'published',
], 'Save');
$stage->publish();
// The admin user can see unpublished nodes.
$this->drupalGet('/node/1');
$this->assertText('First article - archived');
$this->drupalGet('/node/2');
$this->assertText('Second article - published');
}
}
<?php
namespace Drupal\Tests\content_moderation\Kernel;
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\Entity\Node;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\Tests\workspaces\Kernel\WorkspaceTestTrait;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\WorkflowInterface;
use Drupal\workspaces\WorkspaceAccessException;
/**
* Tests that Workspaces and Content Moderation work together properly.
*
* @group content_moderation
* @group workspaces
*/
class WorkspacesContentModerationStateTest extends ContentModerationStateTest {
use ContentModerationTestTrait {
createEditorialWorkflow as traitCreateEditorialWorkflow;
addEntityTypeAndBundleToWorkflow as traitAddEntityTypeAndBundleToWorkflow;
}
use ContentTypeCreationTrait {
createContentType as traitCreateContentType;
}
use UserCreationTrait;
use WorkspaceTestTrait;
/**
* The ID of the revisionable entity type used in the tests.
*
* @var string
*/
protected $revEntityTypeId = 'entity_test_revpub';
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installSchema('system', ['key_value_expire', 'sequences']);
$this->initializeWorkspacesModule();
$this->switchToWorkspace('stage');
}
/**
* Tests the integration between Content Moderation and Workspaces.
*
* @see content_moderation_workspace_access()
*/
public function testContentModerationIntegrationWithWorkspaces() {
$editorial = $this->createEditorialWorkflow();
$access_handler = \Drupal::entityTypeManager()->getAccessControlHandler('workspace');
// Create another workflow which has the same states as the 'editorial' one,
// but it doesn't create default revisions for the 'archived' state. This
// covers the case when two bundles of the same entity type use different
// workflows with same moderation state names but with different settings.
$editorial_2_values = $editorial->toArray();
unset($editorial_2_values['uuid']);
$editorial_2_values['id'] = 'editorial_2';
$editorial_2_values['type_settings']['states']['archived']['default_revision'] = FALSE;
$editorial_2 = Workflow::create($editorial_2_values);
$this->workspaceManager->executeOutsideWorkspace(function () use ($editorial_2) {
$editorial_2->save();
});
// Create two bundles and assign the two workflows for each of them.
$this->createContentType(['type' => 'page']);
$this->addEntityTypeAndBundleToWorkflow($editorial, 'node', 'page');
$this->createContentType(['type' => 'article']);
$this->addEntityTypeAndBundleToWorkflow($editorial_2, 'node', 'article');
// Create three entities for each bundle, covering all the available
// moderation states.
$page_archived = Node::create(['type' => 'page', 'title' => 'Test page - archived', 'moderation_state' => 'archived']);
$page_archived->save();
$page_draft = Node::create(['type' => 'page', 'title' => 'Test page - draft', 'moderation_state' => 'draft']);
$page_draft->save();
$page_published = Node::create(['type' => 'page', 'title' => 'Test page - published', 'moderation_state' => 'published']);
$page_published->save();
$article_archived = Node::create(['type' => 'article', 'title' => 'Test article - archived', 'moderation_state' => 'archived']);
$article_archived->save();
$article_draft = Node::create(['type' => 'article', 'title' => 'Test article - draft', 'moderation_state' => 'draft']);
$article_draft->save();
$article_published = Node::create(['type' => 'article', 'title' => 'Test article - published', 'moderation_state' => 'published']);
$article_published->save();
// We have three items in a non-default moderation state:
// - $page_draft
// - $article_archived
// - $article_draft
// Therefore the workspace can not be published.
// This assertion also covers two moderation states from different workflows
// with the same name ('archived'), but with different default revision
// settings.
try {
$this->workspaces['stage']->publish();
$this->fail('The expected exception was not thrown.');
}
catch (WorkspaceAccessException $e) {
$this->assertEquals('The Stage workspace can not be published because it contains 3 items in an unpublished moderation state.', $e->getMessage());
}
// Get the $page_draft node to a publishable state and try again.
$page_draft->moderation_state->value = 'published';
$page_draft->save();
try {
$access_handler->resetCache();
$this->workspaces['stage']->publish();
$this->fail('The expected exception was not thrown.');
}
catch (WorkspaceAccessException $e) {
$this->assertEquals('The Stage workspace can not be published because it contains 2 items in an unpublished moderation state.', $e->getMessage());
}
// Get the $article_archived node to a publishable state and try again.
$article_archived->moderation_state->value = 'published';
$article_archived->save();
try {
$access_handler->resetCache();
$this->workspaces['stage']->publish();
$this->fail('The expected exception was not thrown.');
}
catch (WorkspaceAccessException $e) {
$this->assertEquals('The Stage workspace can not be published because it contains 1 item in an unpublished moderation state.', $e->getMessage());
}
// Get the $article_draft node to a publishable state and try again.
$article_draft->moderation_state->value = 'published';
$article_draft->save();
$access_handler->resetCache();
$this->workspaces['stage']->publish();
}
/**
* Test cases for basic moderation test.
*/
public function basicModerationTestCases() {
return [
'Nodes' => [
'node',
],
'Block content' => [
'block_content',
],
'Media' => [
'media',
],
'Test entity - revisions, data table, and published interface' => [
'entity_test_mulrevpub',
],
'Entity Test with revisions and published status' => [
'entity_test_revpub',
],
];
}
/**
* {@inheritdoc}
*/
public function testModerationWithFieldConfigOverride() {
// This test does not assert anything that can be workspace-specific.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
public function testWorkflowDependencies() {
// This test does not assert anything that can be workspace-specific.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
public function testWorkflowNonConfigBundleDependencies() {
// This test does not assert anything that can be workspace-specific.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
public function testGetCurrentUserId() {
// This test does not assert anything that can be workspace-specific.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
protected function createEntity($entity_type_id, $moderation_state = 'published', $create_workflow = TRUE) {
$entity = $this->workspaceManager->executeOutsideWorkspace(function () use ($entity_type_id, $moderation_state, $create_workflow) {
return parent::createEntity($entity_type_id, $moderation_state, $create_workflow);
});
return $entity;
}
/**
* {@inheritdoc}
*/
protected function createEditorialWorkflow() {
$workflow = $this->workspaceManager->executeOutsideWorkspace(function () {
return $this->traitCreateEditorialWorkflow();
});
return $workflow;
}
/**
* {@inheritdoc}
*/
protected function addEntityTypeAndBundleToWorkflow(WorkflowInterface $workflow, $entity_type_id, $bundle) {
$this->workspaceManager->executeOutsideWorkspace(function () use ($workflow, $entity_type_id, $bundle) {
$this->traitAddEntityTypeAndBundleToWorkflow($workflow, $entity_type_id, $bundle);
});
}
/**
* {@inheritdoc}
*/
protected function createContentType(array $values = []) {
$note_type = $this->workspaceManager->executeOutsideWorkspace(function () use ($values) {
return $this->traitCreateContentType($values);
});
return $note_type;
}
/**
* {@inheritdoc}
*/
protected function assertDefaultRevision(EntityInterface $entity, $revision_id, $published = TRUE) {
// In the context of a workspace, the default revision ID is always the
// latest workspace-specific revision, so we need to adjust the expectation
// of the parent assertion.
$revision_id = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->load($entity->id())->getRevisionId();
// Additionally, the publishing status of the default revision is not
// relevant in a workspace, because getting an entity to a "published"
// moderation state doesn't automatically make it the default revision, so
// we have to disable that assertion.
$published = NULL;
parent::assertDefaultRevision($entity, $revision_id, $published);
}
}
......@@ -3,6 +3,7 @@
namespace Drupal\Tests\content_moderation\Traits;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\WorkflowInterface;
/**
* Trait ContentModerationTestTraint.
......@@ -85,4 +86,19 @@ protected function createEditorialWorkflow() {
return $workflow;
}
/**
* Adds an entity type ID / bundle ID to the given workflow.
*
* @param \Drupal\workflows\WorkflowInterface $workflow
* A workflow object.
* @param string $entity_type_id
* The entity type ID to add.
* @param string $bundle
* The bundle ID to add.
*/
protected function addEntityTypeAndBundleToWorkflow(WorkflowInterface $workflow, $entity_type_id, $bundle) {
$workflow->getTypePlugin()->addEntityTypeAndBundle($entity_type_id, $bundle);
$workflow->save();
}
}
......@@ -97,6 +97,7 @@ function entity_test_entity_type_alter(array &$entity_types) {
// Allow entity_test_rev tests to override the entity type definition.
$entity_types['entity_test_rev'] = $state->get('entity_test_rev.entity_type', $entity_types['entity_test_rev']);
$entity_types['entity_test_revpub'] = $state->get('entity_test_revpub.entity_type', $entity_types['entity_test_revpub']);
// Enable the entity_test_new only when needed.
if (!$state->get('entity_test_new')) {
......
<?php
namespace Drupal\entity_test\Entity;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityPublishedTrait;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Defines the test entity class.
*
* @ContentEntityType(
* id = "entity_test_revpub",
* label = @Translation("Test entity - revisions and publishing status"),
* handlers = {
* "access" = "Drupal\entity_test\EntityTestAccessControlHandler",
* "view_builder" = "Drupal\entity_test\EntityTestViewBuilder",
* "form" = {
* "default" = "Drupal\entity_test\EntityTestForm",
* "delete" = "Drupal\entity_test\EntityTestDeleteForm",
* "delete-multiple-confirm" = "Drupal\Core\Entity\Form\DeleteMultipleForm"
* },
* "view_builder" = "Drupal\entity_test\EntityTestViewBuilder",
* },
* base_table = "entity_test_revpub",
* revision_table = "entity_test_revpub_revision",
* admin_permission = "administer entity_test content",
* show_revision_ui = TRUE,
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "revision" = "revision_id",
* "bundle" = "type",
* "label" = "name",
* "langcode" = "langcode",
* "published" = "status",
* },
* links = {
* "add-form" = "/entity_test_rev/add",
* "canonical" = "/entity_test_rev/manage/{entity_test_rev}",
* "delete-form" = "/entity_test/delete/entity_test_rev/{entity_test_rev}",
* "delete-multiple-form" = "/entity_test_rev/delete_multiple",
* "edit-form" = "/entity_test_rev/manage/{entity_test_rev}/edit",
* "revision" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view",
* }