');
+ $page = $this->getSession()->getPage();
+
+ $this->assertTrue($page->hasContent('Workspace switcher'));
+ }
+
+ /**
+ * Sets a given workspace as "active" for subsequent requests.
+ *
+ * This assumes that the switcher block has already been setup by calling
+ * setupWorkspaceSwitcherBlock().
+ *
+ * @param \Drupal\workspace\WorkspaceInterface $workspace
+ * The workspace to set active.
+ */
+ protected function switchToWorkspace(WorkspaceInterface $workspace) {
+ /** @var \Drupal\workspace\WorkspaceManager $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+ if ($workspace_manager->getActiveWorkspace()->id() !== $workspace->id()) {
+ // Switch the system under test to the specified workspace.
+ /** @var \Drupal\Tests\WebAssert $session */
+ $session = $this->assertSession();
+ $session->buttonExists('Activate');
+ $this->drupalPostForm(NULL, ['workspace_id' => $workspace->id()], t('Activate'));
+ $session->pageTextContains($workspace->label() . ' is now the active workspace.');
+
+ // Switch the test runner's context to the specified workspace.
+ \Drupal::service('workspace.manager')->setActiveWorkspace($workspace);
+
+ // If we don't do both of those, test runner utility methods will not be
+ // run in the same workspace as the system under test, and you'll be left
+ // wondering why your test runner cannot find content you just created.
+ }
+ }
+
+ /**
+ * 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.
+ */
+ protected function createNodeType($label, $machine_name) {
+ $node_type = NodeType::create([
+ 'type' => $machine_name,
+ 'label' => $label,
+ ]);
+ $node_type->save();
+ }
+
+ /**
+ * Creates a node by "clicking" buttons.
+ *
+ * @param string $label
+ * The label of the Node to create.
+ * @param string $bundle
+ * The bundle of the Node to create.
+ * @param bool $publish
+ * The publishing status to set.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface
+ * The Node that was just created.
+ *
+ * @throws \Behat\Mink\Exception\ElementNotFoundException
+ */
+ protected function createNodeThroughUi($label, $bundle, $publish = TRUE) {
+ $this->drupalGet('/node/add/' . $bundle);
+
+ /** @var \Behat\Mink\Session $session */
+ $session = $this->getSession();
+ $this->assertSession()->statusCodeEquals(200);
+
+ /** @var \Behat\Mink\Element\DocumentElement $page */
+ $page = $session->getPage();
+ $page->fillField('Title', $label);
+ if ($publish) {
+ $page->findButton(t('Save'))->click();
+ }
+ else {
+ $page->uncheckField('Published');
+ $page->findButton(t('Save'))->click();
+ }
+
+ $session->getPage()->hasContent("{$label} has been created");
+
+ return $this->getOneEntityByLabel('node', $label);
+ }
+
+ /**
+ * Determine if the content list has an entity's label.
+ *
+ * This assertion can be used to validate a particular entity exists in the
+ * current workspace.
+ */
+ protected function isLabelInContentOverview($label) {
+ $this->drupalGet('/admin/content');
+ $session = $this->getSession();
+ $this->assertSession()->statusCodeEquals(200);
+ $page = $session->getPage();
+ return $page->hasContent($label);
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
new file mode 100644
index 0000000..a0ed21b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
@@ -0,0 +1,41 @@
+drupalLogin($this->rootUser);
+ $this->drupalGet('/admin/modules/uninstall');
+ $session = $this->assertSession();
+ $session->linkExists('Remove workspaces');
+ $this->clickLink('Remove workspaces');
+ $session->pageTextContains('Are you sure you want to delete all workspaces?');
+ $this->drupalPostForm('/admin/modules/uninstall/entity/workspace', [], 'Delete all workspaces');
+ $this->drupalPostForm('admin/modules/uninstall', ['uninstall[workspace]' => TRUE], 'Uninstall');
+ $this->drupalPostForm(NULL, [], 'Uninstall');
+ $session->pageTextContains('The selected modules have been uninstalled.');
+ $session->pageTextNotContains('Workspace');
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
new file mode 100644
index 0000000..16d13ef
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
@@ -0,0 +1,102 @@
+drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+
+ $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ $bears = Workspace::load('bears');
+
+ // Now login as a different user and create a workspace.
+ $editor2 = $this->drupalCreateUser($permissions);
+
+ $this->drupalLogin($editor2);
+ $session = $this->getSession();
+
+ $this->createWorkspaceThroughUi('Packers', 'packers');
+
+ $packers = Workspace::load('packers');
+
+ // Load the activate form for the Bears workspace. It should fail because
+ // the workspace belongs to someone else.
+ $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+ $this->assertEquals(403, $session->getStatusCode());
+
+ // But editor 2 should be able to activate the Packers workspace.
+ $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+ $this->assertEquals(200, $session->getStatusCode());
+ }
+
+ /**
+ * Verifies that a user can view any workspace.
+ */
+ public function testViewAnyWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'edit own workspace',
+ 'view any workspace',
+ ];
+
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+
+ $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ $bears = Workspace::load('bears');
+
+ // Now login as a different user and create a workspace.
+ $editor2 = $this->drupalCreateUser($permissions);
+
+ $this->drupalLogin($editor2);
+ $session = $this->getSession();
+
+ $this->createWorkspaceThroughUi('Packers', 'packers');
+
+ $packers = Workspace::load('packers');
+
+ // Load the activate form for the Bears workspace. This user should be
+ // able to see both workspaces because of the "view any" permission.
+ $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+ $this->assertEquals(200, $session->getStatusCode());
+
+ // But editor 2 should be able to activate the Packers workspace.
+ $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+ $this->assertEquals(200, $session->getStatusCode());
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
new file mode 100644
index 0000000..ebc1f09
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
@@ -0,0 +1,107 @@
+installSchema('system', ['sequences']);
+
+ $this->installEntitySchema('workspace');
+ $this->installEntitySchema('user');
+
+ // User 1.
+ $this->createUser();
+ }
+
+ /**
+ * Test cases for testWorkspaceAccess().
+ *
+ * @return array
+ * An array of operations and permissions to test with.
+ */
+ public function operationCases() {
+ return [
+ ['create', 'create workspace'],
+ ['create', 'view workspace oak'],
+ ['create', 'view any workspace'],
+ ['create', 'view own workspace'],
+ ['create', 'edit workspace oak'],
+ ['create', 'edit any workspace'],
+ ['create', 'edit own workspace'],
+ ['view', 'create workspace'],
+ ['view', 'view workspace oak'],
+ ['view', 'view any workspace'],
+ ['view', 'view own workspace'],
+ ['view', 'edit workspace oak'],
+ ['view', 'edit any workspace'],
+ ['view', 'edit own workspace'],
+ ['update', 'create workspace'],
+ ['update', 'view workspace oak'],
+ ['update', 'view any workspace'],
+ ['update', 'view own workspace'],
+ ['update', 'edit workspace oak'],
+ ['update', 'edit any workspace'],
+ ['update', 'edit own workspace'],
+ ['delete', 'create workspace'],
+ ['delete', 'view workspace oak'],
+ ['delete', 'view any workspace'],
+ ['delete', 'view own workspace'],
+ ['delete', 'edit workspace oak'],
+ ['delete', 'edit any workspace'],
+ ['delete', 'edit own workspace'],
+ ];
+ }
+
+ /**
+ * Verifies all workspace roles have the correct access for the operation.
+ *
+ * @param string $operation
+ * The operation to test with.
+ * @param string $permission
+ * The permission to test with.
+ *
+ * @dataProvider operationCases
+ */
+ public function testWorkspaceAccess($operation, $permission) {
+ $user = $this->createUser();
+ $this->setCurrentUser($user);
+ $workspace = Workspace::create(['id' => 'oak']);
+ $workspace->save();
+ $role = $this->createRole([$permission]);
+ $user->addRole($role);
+ $operation_permission = $operation === 'update' ? 'edit' : $operation;
+ if (strpos($permission, $operation_permission) === FALSE || $operation === 'delete') {
+ $this->assertFalse($workspace->access($operation, $user));
+ }
+ else {
+ $this->assertTrue($workspace->access($operation, $user));
+ }
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
new file mode 100644
index 0000000..bb50147
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
@@ -0,0 +1,633 @@
+entityTypeManager = \Drupal::entityTypeManager();
+
+ $this->installConfig(['filter', 'node', 'system']);
+
+ $this->installSchema('system', ['key_value_expire', 'sequences']);
+ $this->installSchema('node', ['node_access']);
+
+ $this->installEntitySchema('entity_test_mulrev');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+
+ $this->createContentType(['type' => 'page']);
+
+ $this->setCurrentUser($this->createUser(['administer nodes']));
+
+ // Create two nodes, a published and an unpublished one, so we can test the
+ // behavior of the module with default/existing content.
+ $this->createdTimestamp = \Drupal::time()->getRequestTime();
+ $this->createNode(['title' => 'live - 1 - r1 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+ $this->createNode(['title' => 'live - 2 - r2 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+ }
+
+ /**
+ * Enables the Workspace module and creates two workspaces.
+ */
+ protected function initializeWorkspaceModule() {
+ // Enable the Workspace module here instead of the static::$modules array so
+ // we can test it with default content.
+ $this->enableModules(['workspace']);
+ $this->container = \Drupal::getContainer();
+ $this->entityTypeManager = \Drupal::entityTypeManager();
+
+ $this->installEntitySchema('workspace');
+ $this->installEntitySchema('content_workspace');
+ $this->installEntitySchema('replication_log');
+
+ // Create two workspaces by default, 'live' and 'stage'.
+ $this->workspaces['live'] = Workspace::create(['id' => 'live']);
+ $this->workspaces['live']->save();
+ $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'upstream' => 'local_workspace:live']);
+ $this->workspaces['stage']->save();
+
+ $permissions = [
+ 'administer nodes',
+ 'create workspace',
+ 'edit any workspace',
+ 'view any workspace',
+ ];
+ $this->setCurrentUser($this->createUser($permissions));
+ }
+
+ /**
+ * Tests various scenarios for creating and deploying content in workspaces.
+ */
+ public function testWorkspaces() {
+ $this->initializeWorkspaceModule();
+
+ // Notes about the structure of the test scenarios:
+ // - 'default_revision' indicates the entity revision that should be
+ // returned by entity_load(), non-revision entity queries and non-revision
+ // views *in a given workspace*, it does not indicate what is actually
+ // stored in the base entity tables.
+ $test_scenarios = [];
+
+ // A multi-dimensional array keyed by the workspace ID, then by the entity
+ // and finally by the revision ID.
+ //
+ // In the initial state we have only the two revisions that were created
+ // before the Workspace module was installed.
+ $revision_state = [
+ 'live' => [
+ 1 => [
+ 1 => [
+ 'title' => 'live - 1 - r1 - published',
+ 'status' => TRUE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ 2 => [
+ 2 => [
+ 'title' => 'live - 2 - r2 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ 'stage' => [
+ 1 => [
+ 1 => [
+ 'title' => 'live - 1 - r1 - published',
+ 'status' => TRUE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ 2 => [
+ 2 => [
+ 'title' => 'live - 2 - r2 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ ];
+ $test_scenarios['initial_state'] = $revision_state;
+
+ // Unpublish node 1 in 'stage'. The new revision is also added to 'live' but
+ // it is not the default revision.
+ $revision_state = array_replace_recursive($revision_state, [
+ 'live' => [
+ 1 => [
+ 3 => [
+ 'title' => 'stage - 1 - r3 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => FALSE,
+ ],
+ ],
+ ],
+ 'stage' => [
+ 1 => [
+ 1 => ['default_revision' => FALSE],
+ 3 => [
+ 'title' => 'stage - 1 - r3 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ ]);
+ $test_scenarios['unpublish_node_1_in_stage'] = $revision_state;
+
+ // Publish node 2 in 'stage'. The new revision is also added to 'live' but
+ // it is not the default revision.
+ $revision_state = array_replace_recursive($revision_state, [
+ 'live' => [
+ 2 => [
+ 4 => [
+ 'title' => 'stage - 2 - r4 - published',
+ 'status' => TRUE,
+ 'default_revision' => FALSE,
+ ],
+ ],
+ ],
+ 'stage' => [
+ 2 => [
+ 2 => ['default_revision' => FALSE],
+ 4 => [
+ 'title' => 'stage - 2 - r4 - published',
+ 'status' => TRUE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ ]);
+ $test_scenarios['publish_node_2_in_stage'] = $revision_state;
+
+ // Adding a new unpublished node on 'stage' should create a single
+ // unpublished revision on both 'stage' and 'live'.
+ $revision_state = array_replace_recursive($revision_state, [
+ 'live' => [
+ 3 => [
+ 5 => [
+ 'title' => 'stage - 3 - r5 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ 'stage' => [
+ 3 => [
+ 5 => [
+ 'title' => 'stage - 3 - r5 - unpublished',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ ]);
+ $test_scenarios['add_unpublished_node_in_stage'] = $revision_state;
+
+ // Adding a new published node on 'stage' should create two revisions, an
+ // unpublished revision on 'live' and a published one on 'stage'.
+ $revision_state = array_replace_recursive($revision_state, [
+ 'live' => [
+ 4 => [
+ 6 => [
+ 'title' => 'stage - 4 - r6 - published',
+ 'status' => FALSE,
+ 'default_revision' => TRUE,
+ ],
+ 7 => [
+ 'title' => 'stage - 4 - r6 - published',
+ 'status' => TRUE,
+ 'default_revision' => FALSE,
+ ],
+ ],
+ ],
+ 'stage' => [
+ 4 => [
+ 6 => [
+ 'title' => 'stage - 4 - r6 - published',
+ 'status' => FALSE,
+ 'default_revision' => FALSE,
+ ],
+ 7 => [
+ 'title' => 'stage - 4 - r6 - published',
+ 'status' => TRUE,
+ 'default_revision' => TRUE,
+ ],
+ ],
+ ],
+ ]);
+ $test_scenarios['add_published_node_in_stage'] = $revision_state;
+
+ // Deploying 'stage' to 'live' should simply make the latest revisions in
+ // 'stage' the default ones in 'live'.
+ $revision_state = array_replace_recursive($revision_state, [
+ 'live' => [
+ 1 => [
+ 1 => ['default_revision' => FALSE],
+ 3 => ['default_revision' => TRUE],
+ ],
+ 2 => [
+ 2 => ['default_revision' => FALSE],
+ 4 => ['default_revision' => TRUE],
+ ],
+ // Node 3 has a single revision for both 'stage' and 'live' and it is
+ // already the default revision in both of them.
+ 4 => [
+ 6 => ['default_revision' => FALSE],
+ 7 => ['default_revision' => TRUE],
+ ],
+ ],
+ ]);
+ $test_scenarios['deploy_stage_to_live'] = $revision_state;
+
+ // Check the initial state after the module was installed.
+ $this->assertWorkspaceStatus($test_scenarios['initial_state'], 'node');
+
+ // Unpublish node 1 in 'stage'.
+ $this->switchToWorkspace('stage');
+ $node = $this->entityTypeManager->getStorage('node')->load(1);
+ $node->setTitle('stage - 1 - r3 - unpublished');
+ $node->setUnpublished();
+ $node->save();
+ $this->assertWorkspaceStatus($test_scenarios['unpublish_node_1_in_stage'], 'node');
+
+ // Publish node 2 in 'stage'.
+ $this->switchToWorkspace('stage');
+ $node = $this->entityTypeManager->getStorage('node')->load(2);
+ $node->setTitle('stage - 2 - r4 - published');
+ $node->setPublished();
+ $node->save();
+ $this->assertWorkspaceStatus($test_scenarios['publish_node_2_in_stage'], 'node');
+
+ // Add a new unpublished node on 'stage'.
+ $this->switchToWorkspace('stage');
+ $this->createNode(['title' => 'stage - 3 - r5 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+ $this->assertWorkspaceStatus($test_scenarios['add_unpublished_node_in_stage'], 'node');
+
+ // Add a new published node on 'stage'.
+ $this->switchToWorkspace('stage');
+ $this->createNode(['title' => 'stage - 4 - r6 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+ $this->assertWorkspaceStatus($test_scenarios['add_published_node_in_stage'], 'node');
+
+ // Deploy 'stage' to 'live'.
+ $repository_handler = $this->workspaces['stage']->getRepositoryHandlerPlugin();
+ $repository_handler->replicate($this->workspaces['stage']->getLocalRepositoryHandlerPlugin(), $repository_handler);
+ $this->assertWorkspaceStatus($test_scenarios['deploy_stage_to_live'], 'node');
+ }
+
+ /**
+ * Tests the Entity Query relationship API with workspaces.
+ */
+ public function testEntityQueryRelationship() {
+ $this->initializeWorkspaceModule();
+
+ // Add an entity reference field that targets 'entity_test_mulrev' entities.
+ $this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrev');
+
+ // Add an entity reference field that targets 'node' entities so we can test
+ // references to the same base tables.
+ $this->createEntityReferenceField('node', 'page', 'field_test_node', 'Test node reference', 'node');
+
+ $this->switchToWorkspace('live');
+ $node_1 = $this->createNode([
+ 'title' => 'live node 1'
+ ]);
+ $entity_test = EntityTestMulRev::create([
+ 'name' => 'live entity_test_mulrev',
+ 'non_rev_field' => 'live non-revisionable value',
+ ]);
+ $entity_test->save();
+
+ $node_2 = $this->createNode([
+ 'title' => 'live node 2',
+ 'field_test_entity' => $entity_test->id(),
+ 'field_test_node' => $node_1->id(),
+ ]);
+
+ // Switch to the 'stage' workspace and change some values for the referenced
+ // entities.
+ $this->switchToWorkspace('stage');
+ $node_1->title->value = 'stage node 1';
+ $node_1->save();
+
+ $node_2->title->value = 'stage node 2';
+ $node_2->save();
+
+ $entity_test->name->value = 'stage entity_test_mulrev';
+ $entity_test->non_rev_field->value = 'stage non-revisionable value';
+ $entity_test->save();
+
+ // Make sure that we're requesting the default revision.
+ $query = $this->entityTypeManager->getStorage('node')->getQuery();
+ $query->currentRevision();
+
+ $query
+ // Check a condition on the revision data table.
+ ->condition('title', 'stage node 2')
+ // Check a condition on the revision table.
+ ->condition('revision_uid', $node_2->getRevisionUserId())
+ // Check a condition on the data table.
+ ->condition('type', $node_2->bundle())
+ // Check a condition on the base table.
+ ->condition('uuid', $node_2->uuid());
+
+ // Add conditions for a reference to the same entity type.
+ $query
+ // Check a condition on the revision data table.
+ ->condition('field_test_node.entity.title', 'stage node 1')
+ // Check a condition on the revision table.
+ ->condition('field_test_node.entity.revision_uid', $node_1->getRevisionUserId())
+ // Check a condition on the data table.
+ ->condition('field_test_node.entity.type', $node_1->bundle())
+ // Check a condition on the base table.
+ ->condition('field_test_node.entity.uuid', $node_1->uuid());
+
+ // Add conditions for a reference to a different entity type.
+ $query
+ // Check a condition on the revision data table.
+ ->condition('field_test_entity.entity.name', 'stage entity_test_mulrev')
+ // Check a condition on the data table.
+ ->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value')
+ // Check a condition on the base table.
+ ->condition('field_test_entity.entity.uuid', $entity_test->uuid());
+
+ $result = $query->execute();
+ $this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result);
+ }
+
+ /**
+ * Checks entity load, entity queries and views results for a test scenario.
+ *
+ * @param array $expected
+ * An array of expected values, as defined in ::testWorkspaces().
+ * @param string $entity_type_id
+ * The ID of the entity type that is being tested.
+ */
+ protected function assertWorkspaceStatus(array $expected, $entity_type_id) {
+ $expected = $this->flattenExpectedValues($expected, $entity_type_id);
+
+ $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+ foreach ($expected as $workspace_id => $expected_values) {
+ $this->switchToWorkspace($workspace_id);
+
+ // Check that default revisions are swapped with the workspace revision.
+ $this->assertEntityLoad($expected_values, $entity_type_id);
+
+ // Check that non-default revisions are not changed.
+ $this->assertEntityRevisionLoad($expected_values, $entity_type_id);
+
+ // Check that entity queries return the correct results.
+ $this->assertEntityQuery($expected_values, $entity_type_id);
+
+ // Check that the 'Frontpage' view only shows published content that is
+ // also considered as the default revision in the given workspace.
+ $expected_frontpage = array_filter($expected_values, function ($expected_value) {
+ return $expected_value['status'] === TRUE && $expected_value['default_revision'] === TRUE;
+ });
+ // The 'Frontpage' view will output nodes in reverse creation order.
+ usort($expected_frontpage, function ($a, $b) {
+ return $b['nid'] - $a['nid'];
+ });
+ $view = Views::getView('frontpage');
+ $view->execute();
+ $this->assertIdenticalResultset($view, $expected_frontpage, ['nid' => 'nid']);
+
+ $rendered_view = $view->render('page_1');
+ $output = \Drupal::service('renderer')->renderRoot($rendered_view);
+ $this->setRawContent($output);
+ foreach ($expected_values as $expected_entity_values) {
+ if ($expected_entity_values[$entity_keys['published']] === TRUE && $expected_entity_values['default_revision'] === TRUE) {
+ $this->assertRaw($expected_entity_values[$entity_keys['label']]);
+ }
+ // Node 4 will always appear in the 'stage' workspace because it has
+ // both an unpublished revision as well as a published one.
+ elseif ($workspace_id != 'stage' && $expected_entity_values[$entity_keys['id']] != 4) {
+ $this->assertNoRaw($expected_entity_values[$entity_keys['label']]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Asserts that default revisions are properly swapped in a workspace.
+ *
+ * @param array $expected_values
+ * An array of expected values, as defined in ::testWorkspaces().
+ * @param string $entity_type_id
+ * The ID of the entity type to check.
+ */
+ protected function assertEntityLoad(array $expected_values, $entity_type_id) {
+ // Filter the expected values so we can check only the default revisions.
+ $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+ return $expected_value['default_revision'] === TRUE;
+ });
+
+ $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+ $id_key = $entity_keys['id'];
+ $revision_key = $entity_keys['revision'];
+ $label_key = $entity_keys['label'];
+ $published_key = $entity_keys['published'];
+
+ // Check \Drupal\Core\Entity\EntityStorageInterface::loadMultiple().
+ /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+ $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple(array_column($expected_default_revisions, $id_key));
+ foreach ($expected_default_revisions as $expected_default_revision) {
+ $entity_id = $expected_default_revision[$id_key];
+ $this->assertEquals($expected_default_revision[$revision_key], $entities[$entity_id]->getRevisionId());
+ $this->assertEquals($expected_default_revision[$label_key], $entities[$entity_id]->label());
+ $this->assertEquals($expected_default_revision[$published_key], $entities[$entity_id]->isPublished());
+ }
+
+ // Check \Drupal\Core\Entity\EntityStorageInterface::loadUnchanged().
+ foreach ($expected_default_revisions as $expected_default_revision) {
+ /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+ $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadUnchanged($expected_default_revision[$id_key]);
+ $this->assertEquals($expected_default_revision[$revision_key], $entity->getRevisionId());
+ $this->assertEquals($expected_default_revision[$label_key], $entity->label());
+ $this->assertEquals($expected_default_revision[$published_key], $entity->isPublished());
+ }
+ }
+
+ /**
+ * Asserts that non-default revisions are not changed.
+ *
+ * @param array $expected_values
+ * An array of expected values, as defined in ::testWorkspaces().
+ * @param string $entity_type_id
+ * The ID of the entity type to check.
+ */
+ protected function assertEntityRevisionLoad(array $expected_values, $entity_type_id) {
+ $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+ $id_key = $entity_keys['id'];
+ $revision_key = $entity_keys['revision'];
+ $label_key = $entity_keys['label'];
+ $published_key = $entity_keys['published'];
+
+ /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+ $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_column($expected_values, $revision_key));
+ foreach ($expected_values as $expected_revision) {
+ $revision_id = $expected_revision[$revision_key];
+ $this->assertEquals($expected_revision[$id_key], $entities[$revision_id]->id());
+ $this->assertEquals($expected_revision[$revision_key], $entities[$revision_id]->getRevisionId());
+ $this->assertEquals($expected_revision[$label_key], $entities[$revision_id]->label());
+ $this->assertEquals($expected_revision[$published_key], $entities[$revision_id]->isPublished());
+ }
+ }
+
+ /**
+ * Asserts that entity queries are giving the correct results in a workspace.
+ *
+ * @param array $expected_values
+ * An array of expected values, as defined in ::testWorkspaces().
+ * @param string $entity_type_id
+ * The ID of the entity type to check.
+ */
+ protected function assertEntityQuery(array $expected_values, $entity_type_id) {
+ $storage = $this->entityTypeManager->getStorage($entity_type_id);
+ $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+ $id_key = $entity_keys['id'];
+ $revision_key = $entity_keys['revision'];
+ $label_key = $entity_keys['label'];
+ $published_key = $entity_keys['published'];
+
+ // Filter the expected values so we can check only the default revisions.
+ $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+ return $expected_value['default_revision'] === TRUE;
+ });
+
+ // Check entity query counts.
+ $result = $storage->getQuery()->count()->execute();
+ $this->assertEquals(count($expected_default_revisions), $result);
+
+ $result = $storage->getAggregateQuery()->count()->execute();
+ $this->assertEquals(count($expected_default_revisions), $result);
+
+ // Check entity queries with no conditions.
+ $result = $storage->getQuery()->execute();
+ $expected_result = array_combine(array_column($expected_default_revisions, $revision_key), array_column($expected_default_revisions, $id_key));
+ $this->assertEquals($expected_result, $result);
+
+ // Check querying each revision individually.
+ foreach ($expected_values as $expected_value) {
+ $query = $storage->getQuery();
+ $query
+ ->condition($entity_keys['id'], $expected_value[$id_key])
+ ->condition($entity_keys['label'], $expected_value[$label_key])
+ ->condition($entity_keys['published'], $expected_value[$published_key]);
+
+ // If the entity is not expected to be the default revision, we need to
+ // query all revisions if we want to find it.
+ if (!$expected_value['default_revision']) {
+ $query->allRevisions();
+ }
+
+ $result = $query->execute();
+ $this->assertEquals([$expected_value[$revision_key] => $expected_value[$id_key]], $result);
+ }
+ }
+
+ /**
+ * Sets a given workspace as active.
+ *
+ * @param string $workspace_id
+ * The ID of the workspace to switch to.
+ */
+ protected function switchToWorkspace($workspace_id) {
+ /** @var \Drupal\workspace\WorkspaceManager $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+ if ($workspace_manager->getActiveWorkspace()->id() !== $workspace_id) {
+ // Switch the test runner's context to the specified workspace.
+ $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+ \Drupal::service('workspace.manager')->setActiveWorkspace($workspace);
+ }
+ }
+
+ /**
+ * Flattens the expectations array defined by testWorkspaces().
+ *
+ * @param array $expected
+ * An array as defined by testWorkspaces().
+ * @param string $entity_type_id
+ * The ID of the entity type that is being tested.
+ *
+ * @return array
+ * An array where all the entity IDs and revision IDs are merged inside each
+ * expected values array.
+ */
+ protected function flattenExpectedValues(array $expected, $entity_type_id) {
+ $flattened = [];
+
+ $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+ foreach ($expected as $workspace_id => $workspace_values) {
+ foreach ($workspace_values as $entity_id => $entity_revisions) {
+ foreach ($entity_revisions as $revision_id => $revision_values) {
+ $flattened[$workspace_id][] = [$entity_keys['id'] => $entity_id, $entity_keys['revision'] => $revision_id] + $revision_values;
+ }
+ }
+ }
+
+ return $flattened;
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
new file mode 100644
index 0000000..08c14fc
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
@@ -0,0 +1,62 @@
+setExpectedException(PluginNotFoundException::class, 'The "entity:content_workspace" plugin does not exist.');
+ RestResourceConfig::create([
+ 'id' => 'entity.content_workspace',
+ 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+ 'configuration' => [
+ 'methods' => ['GET'],
+ 'formats' => ['json'],
+ 'authentication' => ['cookie'],
+ ],
+ ])
+ ->enable()
+ ->save();
+ }
+
+ /**
+ * Tests enabling replication logs for REST throws an exception.
+ *
+ * @see workspace_rest_resource_alter()
+ */
+ public function testCreateReplicationLogResource() {
+ $this->setExpectedException(PluginNotFoundException::class, 'The "entity:replication_log" plugin does not exist.');
+ RestResourceConfig::create([
+ 'id' => 'entity.replication_log',
+ 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+ 'configuration' => [
+ 'methods' => ['GET'],
+ 'formats' => ['json'],
+ 'authentication' => ['cookie'],
+ ],
+ ])
+ ->enable()
+ ->save();
+ }
+
+}
diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml
new file mode 100644
index 0000000..9599e07
--- /dev/null
+++ b/core/modules/workspace/workspace.info.yml
@@ -0,0 +1,9 @@
+name: Workspace
+type: module
+description: 'Provides the ability to have multiple workspaces on a single site to facilitate things like full-site preview and content staging.'
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: entity.workspace.collection
+dependencies:
+ - user
diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install
new file mode 100644
index 0000000..0b3549e
--- /dev/null
+++ b/core/modules/workspace/workspace.install
@@ -0,0 +1,47 @@
+getStorage('user_role')->getQuery()
+ ->condition('is_admin', TRUE)
+ ->execute();
+ if (!empty($admin_roles)) {
+ $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery()
+ ->condition('roles', $admin_roles, 'IN')
+ ->condition('status', 1)
+ ->sort('uid', 'ASC')
+ ->range(0, 1);
+ $result = $query->execute();
+ }
+
+ // Default to user ID 1 if we could not find any other administrator users.
+ $owner_id = !empty($result) ? reset($result) : 1;
+
+ // Create two workspaces by default, 'live' and 'stage'.
+ Workspace::create([
+ 'id' => 'live',
+ 'label' => 'Live',
+ 'upstream' => RepositoryHandlerInterface::EMPTY_VALUE,
+ 'uid' => $owner_id,
+ ])->save();
+
+ Workspace::create([
+ 'id' => 'stage',
+ 'label' => 'Stage',
+ 'upstream' => 'local_workspace:live',
+ 'uid' => $owner_id,
+ ])->save();
+}
diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml
new file mode 100644
index 0000000..c7aad42
--- /dev/null
+++ b/core/modules/workspace/workspace.libraries.yml
@@ -0,0 +1,5 @@
+drupal.workspace.toolbar:
+ version: VERSION
+ css:
+ theme:
+ css/workspace.toolbar.css: {}
diff --git a/core/modules/workspace/workspace.link_relation_types.yml b/core/modules/workspace/workspace.link_relation_types.yml
new file mode 100644
index 0000000..d591603
--- /dev/null
+++ b/core/modules/workspace/workspace.link_relation_types.yml
@@ -0,0 +1,8 @@
+# Workspace extension relation types.
+# See https://tools.ietf.org/html/rfc5988#section-4.2.
+activate-form:
+ uri: https://drupal.org/link-relations/activate-form
+ description: A form where a workspace can be activated.
+deploy-form:
+ uri: https://drupal.org/link-relations/deploy-form
+ description: A form where a workspace can be deployed.
diff --git a/core/modules/workspace/workspace.links.action.yml b/core/modules/workspace/workspace.links.action.yml
new file mode 100644
index 0000000..9f22598
--- /dev/null
+++ b/core/modules/workspace/workspace.links.action.yml
@@ -0,0 +1,5 @@
+entity.workspace.add_form:
+ route_name: entity.workspace.add_form
+ title: 'Add workspace'
+ appears_on:
+ - entity.workspace.collection
diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml
new file mode 100644
index 0000000..c7faefb
--- /dev/null
+++ b/core/modules/workspace/workspace.links.menu.yml
@@ -0,0 +1,5 @@
+entity.workspace.collection:
+ title: 'Workspaces'
+ parent: system.admin_config_workflow
+ description: 'Create and manage workspaces.'
+ route_name: entity.workspace.collection
diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module
new file mode 100644
index 0000000..3b3118e
--- /dev/null
+++ b/core/modules/workspace/workspace.module
@@ -0,0 +1,678 @@
+' . t('About') . '';
+ $output .= '' . t('The Workspace module allows workspaces to be defined and switched between. Content is then assigned to the active workspace when created. For more information, see the online documentation for the Workspace module.', [':workspace' => 'https://www.drupal.org/node/2824024']) . '
';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function workspace_entity_load(array &$entities, $entity_type_id) {
+ /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+ $entity_type_manager = \Drupal::entityTypeManager();
+
+ // Don't alter the loaded entities if the entity type can not belong to a
+ // workspace.
+ if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity_type_manager->getDefinition($entity_type_id))) {
+ return;
+ }
+
+ // Don't alter the loaded entities if the active workspace is the default one.
+ $active_workspace = $workspace_manager->getActiveWorkspace();
+ if ($active_workspace->isDefaultWorkspace()) {
+ return;
+ }
+
+ // Get a list of revision IDs for entities that have a revision set for the
+ // current active workspace. If an entity has multiple revisions set for a
+ // workspace, only the one with the highest ID is returned.
+ $entity_ids = array_keys($entities);
+ $max_revision_id = 'max_content_entity_revision_id';
+ $results = $entity_type_manager
+ ->getStorage('content_workspace')
+ ->getAggregateQuery()
+ ->allRevisions()
+ ->aggregate('content_entity_revision_id', 'MAX', NULL, $max_revision_id)
+ ->groupBy('content_entity_id')
+ ->condition('content_entity_type_id', $entity_type_id)
+ ->condition('content_entity_id', $entity_ids, 'IN')
+ ->condition('workspace', $active_workspace->id(), '=')
+ ->execute();
+
+ // Since hook_entity_load() is called on both regular entity load as well as
+ // entity revision load, we need to prevent infinite recursion by checking
+ // whether the default revisions were already swapped with the workspace
+ // revision.
+ // @todo This recursion protection should be removed when
+ // https://www.drupal.org/project/drupal/issues/2928888 is resolved.
+ if ($results) {
+ foreach ($results as $key => $result) {
+ if ($entities[$result['content_entity_id']]->getRevisionId() == $result[$max_revision_id]) {
+ unset($results[$key]);
+ }
+ }
+ }
+
+ if ($results) {
+ /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
+ $storage = $entity_type_manager->getStorage($entity_type_id);
+
+ // Swap out every entity which has a revision set for the current active
+ // workspace.
+ $swap_revision_ids = array_column($results, $max_revision_id);
+ foreach ($storage->loadMultipleRevisions($swap_revision_ids) as $revision) {
+ $entities[$revision->id()] = $revision;
+ }
+ }
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function workspace_entity_presave(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+ || $workspace_manager->getActiveWorkspace()->isDefaultWorkspace()) {
+ return;
+ }
+
+ // Force a new revision if the entity is not replicating.
+ if (!$entity->isNew() && !isset($entity->_isReplicating)) {
+ $entity->setNewRevision(TRUE);
+
+ // All entities in the non-default workspace are pending revisions,
+ // regardless of their publishing status. This means that when creating
+ // a published pending revision in a non-default workspace it will also be
+ // a published pending revision in the default workspace, however, it will
+ // become the default revision only when it is replicated to the default
+ // workspace.
+ $entity->isDefaultRevision(FALSE);
+ }
+
+ // When a new published entity is inserted in a non-default workspace, we
+ // actually want two revisions to be saved:
+ // - An unpublished default revision in the default ('live') workspace.
+ // - A published pending revision in the current workspace.
+ if ($entity->isNew() && $entity->isPublished()) {
+ // Keep track of the publishing status for workspace_entity_insert() and
+ // unpublish the default revision.
+ $entity->_initialPublished = TRUE;
+ $entity->setUnpublished();
+ }
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function workspace_entity_insert(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+ || $workspace_manager->getActiveWorkspace()->isDefaultWorkspace()) {
+ return;
+ }
+
+ // Handle the case when a new published entity was created in a non-default
+ // workspace and create a published pending revision for it.
+ if (isset($entity->_initialPublished)) {
+ // Operate on a clone to avoid changing the entity prior to subsequent
+ // hook_entity_insert() implementations.
+ $pending_revision = clone $entity;
+ $pending_revision->setPublished();
+ $pending_revision->isDefaultRevision(FALSE);
+ $pending_revision->save();
+ }
+ else {
+ $workspace_manager->updateOrCreateFromEntity($entity);
+ }
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function workspace_entity_update(EntityInterface $entity) {
+ /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+ || $workspace_manager->getActiveWorkspace()->isDefaultWorkspace()) {
+ return;
+ }
+
+ $workspace_manager->updateOrCreateFromEntity($entity);
+}
+
+/**
+ * Implements hook_entity_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityAccess::class)
+ ->entityOperationAccess($entity, $operation, $account);
+}
+
+/**
+ * Implements hook_entity_create_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_create_access(AccountInterface $account, array $context, $entity_bundle) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityAccess::class)
+ ->entityCreateAccess($account, $context, $entity_bundle);
+}
+
+/**
+ * Implements hook_views_query_alter().
+ */
+function workspace_views_query_alter(ViewExecutable $view, QueryPluginBase $query) {
+ /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+ $workspace_manager = \Drupal::service('workspace.manager');
+
+ // Don't alter any views queries if we're in the default workspace.
+ $active_workspace = $workspace_manager->getActiveWorkspace();
+ if ($active_workspace->isDefaultWorkspace()) {
+ return;
+ }
+
+ // Don't alter any non-sql views queries.
+ if (!$query instanceof Sql) {
+ return;
+ }
+
+ /** @var \Drupal\views\ViewsData $views_data */
+ $views_data = \Drupal::service('views.views_data');
+
+ // Find out what entity types are represented in this query.
+ $entity_type_ids = [];
+ /** @var \Drupal\views\Plugin\views\query\Sql $query */
+ foreach ($query->relationships as $info) {
+ $table_data = $views_data->get($info['base']);
+ if (empty($table_data['table']['entity type'])) {
+ continue;
+ }
+ $entity_type_id = $table_data['table']['entity type'];
+ // This construct ensures each entity type exists only once.
+ $entity_type_ids[$entity_type_id] = $entity_type_id;
+ }
+
+ $entity_type_definitions = \Drupal::entityTypeManager()->getDefinitions();
+ foreach ($entity_type_ids as $entity_type_id) {
+ if ($workspace_manager->entityTypeCanBelongToWorkspaces($entity_type_definitions[$entity_type_id])) {
+ _workspace_views_query_alter_entity_type($view, $query, $entity_type_definitions[$entity_type_id]);
+ }
+ }
+}
+
+/**
+ * Alters the entity type tables for a Views query.
+ *
+ * @param \Drupal\views\ViewExecutable $view
+ * The view object about to be processed.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ * The query plugin object for the query.
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition.
+ *
+ * @internal
+ */
+function _workspace_views_query_alter_entity_type(ViewExecutable $view, Sql $query, EntityTypeInterface $entity_type) {
+ // This is only called after we determined that this entity type is involved
+ // in the query, and that a non-default workspace is in use.
+ /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
+ $table_mapping = \Drupal::entityTypeManager()->getStorage($entity_type->id())->getTableMapping();
+ $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($entity_type->id());
+ $dedicated_field_storage_definitions = array_filter($field_storage_definitions, function ($definition) use ($table_mapping) {
+ return $table_mapping->requiresDedicatedTableStorage($definition);
+ });
+ $dedicated_field_data_tables = array_map(function ($definition) use ($table_mapping) {
+ return $table_mapping->getDedicatedDataTableName($definition);
+ }, $dedicated_field_storage_definitions);
+
+ $move_workspace_tables = [];
+ foreach ($query->tableQueue as $alias => &$table_info) {
+ // If we reach the content_workspace array item before any candidates, then
+ // we do not need to move it.
+ if ($table_info['table'] == 'content_workspace') {
+ break;
+ }
+
+ // Any dedicated field table is a candidate.
+ if ($field_name = array_search($table_info['table'], $dedicated_field_data_tables, TRUE)) {
+ $relationship = $table_info['relationship'];
+
+ // There can be reverse relationships used. If so, Workspace can't do
+ // anything with them. Detect this and skip.
+ if ($table_info['join']->field != 'entity_id') {
+ continue;
+ }
+
+ // Get the dedicated revision table name.
+ $new_table_name = $table_mapping->getDedicatedRevisionTableName($field_storage_definitions[$field_name]);
+
+ // Now add the content_workspace table.
+ $content_workspace_table = _workspace_ensure_content_workspace_table($entity_type->id(), $query, $relationship);
+
+ // Update the join to use our COALESCE.
+ $revision_field = $entity_type->getKey('revision');
+ $table_info['join']->leftTable = NULL;
+ $table_info['join']->leftField = "COALESCE($content_workspace_table.content_entity_revision_id, $relationship.$revision_field)";
+
+ // Update the join and the table info to our new table name, and to join
+ // on the revision key.
+ $table_info['table'] = $new_table_name;
+ $table_info['join']->table = $new_table_name;
+ $table_info['join']->field = 'revision_id';
+
+ // Finally, if we added the content_workspace table we have to move it in
+ // the table queue so that it comes before this field.
+ if (empty($move_workspace_tables[$content_workspace_table])) {
+ $move_workspace_tables[$content_workspace_table] = $alias;
+ }
+ }
+ }
+
+ // JOINs must be in order. i.e, any tables you mention in the ON clause of a
+ // JOIN must appear prior to that JOIN. Since we're modifying a JOIN in place,
+ // and adding a new table, we must ensure that the new table appears prior to
+ // this one. So we recorded at what index we saw that table, and then use
+ // array_splice() to move the content_workspace table join to the correct
+ // position.
+ foreach ($move_workspace_tables as $content_workspace_table => $alias) {
+ _workspace_move_entity_table($query, $content_workspace_table, $alias);
+ }
+
+ $base_entity_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable();
+
+ $base_fields = array_diff($table_mapping->getFieldNames($entity_type->getBaseTable()), [$entity_type->getKey('langcode')]);
+ $revisionable_fields = array_diff($table_mapping->getFieldNames($entity_type->getRevisionDataTable()), $base_fields);
+
+ // Go through and look to see if we have to modify fields and filters.
+ foreach ($query->fields as &$field_info) {
+ // Some fields don't actually have tables, meaning they're formulae and
+ // whatnot. At this time we are going to ignore those.
+ if (empty($field_info['table'])) {
+ continue;
+ }
+
+ // Dereference the alias into the actual table.
+ $table = $query->tableQueue[$field_info['table']]['table'];
+ if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
+ $relationship = $query->tableQueue[$field_info['table']]['alias'];
+ $alias = _workspace_ensure_revision_table($entity_type, $query, $relationship);
+ if ($alias) {
+ // Change the base table to use the revision table instead.
+ $field_info['table'] = $alias;
+ }
+ }
+ }
+
+ $relationships = [];
+ // Build a list of all relationships that might be for our table.
+ foreach ($query->relationships as $relationship => $info) {
+ if ($info['base'] == $base_entity_table) {
+ $relationships[] = $relationship;
+ }
+ }
+
+ // Now we have to go through our where clauses and modify any of our fields.
+ foreach ($query->where as &$clauses) {
+ foreach ($clauses['conditions'] as &$where_info) {
+ // Build a matrix of our possible relationships against fields we need to
+ // switch.
+ foreach ($relationships as $relationship) {
+ foreach ($revisionable_fields as $field) {
+ if (is_string($where_info['field']) && $where_info['field'] == "$relationship.$field") {
+ $alias = _workspace_ensure_revision_table($entity_type, $query, $relationship);
+ if ($alias) {
+ // Change the base table to use the revision table instead.
+ $where_info['field'] = "$alias.$field";
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // @todo Handle $query->orderby, $query->groupby, $query->having, $query->count_field
+}
+
+/**
+ * Adds the 'content_workspace' table to a views query.
+ *
+ * @param string $entity_type_id
+ * The ID of the entity type to join.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ * The query plugin object for the query.
+ * @param string $relationship
+ * The primary table alias this table is related to.
+ *
+ * @return string
+ * The alias of the 'content_workspace' table.
+ *
+ * @internal
+ */
+function _workspace_ensure_content_workspace_table($entity_type_id, Sql $query, $relationship) {
+ if (isset($query->tables[$relationship]['content_workspace'])) {
+ return $query->tables[$relationship]['content_workspace']['alias'];
+ }
+
+ $table_data = \Drupal::service('views.views_data')->get($query->relationships[$relationship]['base']);
+
+ // Construct the join.
+ $definition = [
+ 'table' => 'content_workspace',
+ 'field' => 'content_entity_id',
+ 'left_table' => $relationship,
+ 'left_field' => $table_data['table']['base']['field'],
+ 'extra' => [
+ [
+ 'field' => 'content_entity_type_id',
+ 'value' => $entity_type_id,
+ ],
+ [
+ 'field' => 'workspace',
+ 'value' => \Drupal::service('workspace.manager')->getActiveWorkspace()->id(),
+ ],
+ ],
+ 'type' => 'LEFT',
+ ];
+
+ $join = \Drupal::service('plugin.manager.views.join')->createInstance('standard', $definition);
+ $join->adjusted = TRUE;
+
+ return $query->queueTable('content_workspace', $relationship, $join);
+}
+
+/**
+ * Adds the revision table of an entity type to a query object.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ * The query plugin object for the query.
+ * @param string $relationship
+ * The name of the relationship.
+ *
+ * @return string
+ * The alias of the relationship.
+ *
+ * @internal
+ */
+function _workspace_ensure_revision_table(EntityTypeInterface $entity_type, Sql $query, $relationship) {
+ // Get the alias for the 'content_workspace' table we chain off of in the
+ // COALESCE.
+ $content_workspace_table = _workspace_ensure_content_workspace_table($entity_type->id(), $query, $relationship);
+
+ // Get the name of the revision table and revision key.
+ $base_revision_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
+ $revision_field = $entity_type->getKey('revision');
+
+ // If the table was already added and has a join against the same field on
+ // the revision table, reuse that rather than adding a new join.
+ if (isset($query->tables[$relationship][$base_revision_table])) {
+ $alias = $query->tables[$relationship][$base_revision_table]['alias'];
+ if (isset($query->tableQueue[$alias]['join']->field) && $query->tableQueue[$alias]['join']->field == $revision_field) {
+ // If this table previously existed, but was not added by us, we need
+ // to modify the join and make sure that 'content_workspace' comes first.
+ if (empty($query->tableQueue[$alias]['join']->workspace_adjusted)) {
+ $query->tableQueue[$alias]['join'] = _workspace_get_revision_table_join($relationship, $base_revision_table, $revision_field, $content_workspace_table);
+ // We also have to ensure that our 'content_workspace' comes before
+ // this.
+ _workspace_move_entity_table($query, $content_workspace_table, $alias);
+ }
+
+ return $alias;
+ }
+ }
+
+ // Construct a new join.
+ $join = _workspace_get_revision_table_join($relationship, $base_revision_table, $revision_field, $content_workspace_table);
+ return $query->queueTable($base_revision_table, $relationship, $join);
+}
+
+/**
+ * Fetches a join for a revision table using the 'content_workspace' table.
+ *
+ * @param string $relationship
+ * The relationship to use in the view.
+ * @param string $table
+ * The table name.
+ * @param string $field
+ * The field to join on.
+ * @param string $content_workspace_table
+ * The alias of the 'content_workspace' table joined to the main entity table.
+ *
+ * @return \Drupal\views\Plugin\views\join\JoinPluginInterface
+ * An adjusted views join object to add to the query.
+ *
+ * @internal
+ */
+function _workspace_get_revision_table_join($relationship, $table, $field, $content_workspace_table) {
+ $definition = [
+ 'table' => $table,
+ 'field' => $field,
+ // Making this explicitly null allows the left table to be a formula.
+ 'left_table' => NULL,
+ 'left_field' => "COALESCE($content_workspace_table.content_entity_revision_id, $relationship.$field)",
+ ];
+
+ $join = \Drupal::service('plugin.manager.views.join')->createInstance('standard', $definition);
+ $join->adjusted = TRUE;
+ $join->workspace_adjusted = TRUE;
+
+ return $join;
+}
+
+/**
+ * Moves a 'content_workspace' table to appear before the given alias.
+ *
+ * Because Workspace chains possibly pre-existing tables onto the
+ * 'content_workspace' table, we have to ensure that the 'content_workspace'
+ * table appears in the query before the alias it's chained on or the SQL is
+ * invalid. This uses array_slice() to reconstruct the table queue of the query.
+ *
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ * The SQL query object.
+ * @param string $content_workspace_table
+ * The alias of the 'content_workspace' table.
+ * @param string $alias
+ * The alias of the table it needs to appear before.
+ *
+ * @internal
+ */
+function _workspace_move_entity_table(Sql $query, $content_workspace_table, $alias) {
+ $keys = array_keys($query->tableQueue);
+ $current_index = array_search($content_workspace_table, $keys);
+ $index = array_search($alias, $keys);
+
+ // If it's already before our table, we don't need to move it, as we could
+ // accidentally move it forward.
+ if ($current_index < $index) {
+ return;
+ }
+ $splice = [$content_workspace_table => $query->tableQueue[$content_workspace_table]];
+ unset($query->tableQueue[$content_workspace_table]);
+
+ // Now move the item to the proper location in the array. Don't use
+ // array_splice() because that breaks indices.
+ $query->tableQueue = array_slice($query->tableQueue, 0, $index, TRUE) +
+ $splice +
+ array_slice($query->tableQueue, $index, NULL, TRUE);
+}
+
+/**
+ * Implements hook_rest_resource_alter().
+ */
+function workspace_rest_resource_alter(&$definitions) {
+ // ContentWorkspace and ReplicationLog are internal entity types, therefore
+ // they should not be exposed via REST.
+ unset($definitions['entity:content_workspace']);
+ unset($definitions['entity:replication_log']);
+}
+
+/**
+ * Implements hook_toolbar().
+ */
+function workspace_toolbar() {
+ $items = [];
+ $items['workspace'] = [
+ '#cache' => [
+ 'contexts' => [
+ 'user.permissions',
+ ],
+ ],
+ ];
+
+ $current_user = \Drupal::currentUser();
+ if (!$current_user->hasPermission('administer workspaces')
+ || !$current_user->hasPermission('view own workspace')
+ || !$current_user->hasPermission('view any workspace')) {
+ return $items;
+ }
+
+ /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+ $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace();
+
+ $configure_link = NULL;
+ if ($current_user->hasPermission('administer workspaces')) {
+ $configure_link = [
+ '#type' => 'link',
+ '#title' => t('Manage workspaces'),
+ '#url' => $active_workspace->toUrl('collection'),
+ '#options' => ['attributes' => ['class' => ['manage-workspaces']]],
+ ];
+ }
+
+ $items['workspace'] = [
+ '#type' => 'toolbar_item',
+ 'tab' => [
+ '#type' => 'link',
+ '#title' => $active_workspace->label(),
+ '#url' => $active_workspace->toUrl('collection'),
+ '#attributes' => [
+ 'title' => t('Switch workspace'),
+ 'class' => ['toolbar-icon', 'toolbar-icon-workspace'],
+ ],
+ ],
+ 'tray' => [
+ '#heading' => t('Workspaces'),
+ 'workspaces' => workspace_renderable_links(),
+ 'configure' => $configure_link,
+ ],
+ '#wrapper_attributes' => [
+ 'class' => ['workspace-toolbar-tab'],
+ ],
+ '#attached' => [
+ 'library' => ['workspace/drupal.workspace.toolbar'],
+ ],
+ '#weight' => 500,
+ ];
+
+ // Add a special class to the wrapper if we are in the default workspace so we
+ // can highlight it with a different color.
+ if ($active_workspace->isDefaultWorkspace()) {
+ $items['workspace']['#wrapper_attributes']['class'][] = 'is-live';
+ }
+
+ return $items;
+}
+
+/**
+ * Returns an array of workspace activation form links, suitable for rendering.
+ *
+ * @return array
+ * A render array containing links to the workspace activation form.
+ */
+function workspace_renderable_links() {
+ $entity_type_manager = \Drupal::entityTypeManager();
+ /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+ $entity_repository = \Drupal::service('entity.repository');
+ /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+ $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace();
+
+ $links = $cache_tags = [];
+ foreach ($entity_type_manager->getStorage('workspace')->loadMultiple() as $workspace) {
+ $workspace = $entity_repository->getTranslationFromContext($workspace);
+
+ // Add the 'is-active' class for the currently active workspace.
+ $options = [];
+ if ($workspace->id() === $active_workspace->id()) {
+ $options['attributes']['class'][] = 'is-active';
+ }
+
+ // Get the URL of the workspace activation form and display it in a modal.
+ $url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $workspace->id()], $options);
+ if ($url->access()) {
+ $links[$workspace->id()] = [
+ 'type' => 'link',
+ 'title' => $workspace->label(),
+ 'url' => $url,
+ 'attributes' => [
+ 'class' => ['use-ajax'],
+ 'data-dialog-type' => 'modal',
+ 'data-dialog-options' => Json::encode([
+ 'width' => 500,
+ ]),
+ ],
+ ];
+ $cache_tags = Cache::mergeTags($cache_tags, $workspace->getCacheTags());
+ }
+ }
+
+ if (!empty($links)) {
+ $links = [
+ '#theme' => 'links__toolbar_workspaces',
+ '#links' => $links,
+ '#attributes' => [
+ 'class' => ['toolbar-menu'],
+ ],
+ '#cache' => [
+ 'tags' => $cache_tags,
+ ],
+ ];
+ }
+
+ return $links;
+}
diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml
new file mode 100644
index 0000000..36d21aa
--- /dev/null
+++ b/core/modules/workspace/workspace.permissions.yml
@@ -0,0 +1,28 @@
+administer workspaces:
+ title: Administer workspaces
+
+create workspace:
+ title: Create a new workspace
+
+view own workspace:
+ title: View own workspace
+
+view any workspace:
+ title: View any workspace
+
+edit own workspace:
+ title: Edit own workspace
+
+edit any workspace:
+ title: Edit any workspace
+
+bypass entity access own workspace:
+ title: Bypass content entity access in own workspace
+ description: Allow all Edit/Update/Delete permissions for all content entities in a workspace owned by the user.
+ restrict access: TRUE
+
+update any workspace from upstream:
+ title: Update any workspace from upstream
+
+permission_callbacks:
+ - Drupal\workspace\EntityAccess::workspacePermissions
diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml
new file mode 100644
index 0000000..2226501
--- /dev/null
+++ b/core/modules/workspace/workspace.routing.yml
@@ -0,0 +1,27 @@
+entity.workspace.collection:
+ path: '/admin/config/workflow/workspace'
+ defaults:
+ _title: 'Workspaces'
+ _entity_list: 'workspace'
+ requirements:
+ _permission: 'administer workspaces+edit any workspace'
+
+entity.workspace.activate_form:
+ path: '/admin/config/workflow/workspace/{workspace}/activate'
+ defaults:
+ _entity_form: 'workspace.activate'
+ _title: 'Activate Workspace'
+ options:
+ _admin_route: TRUE
+ requirements:
+ _entity_access: 'workspace.view'
+
+entity.workspace.deploy_form:
+ path: '/admin/config/workflow/workspace/{workspace}/deploy'
+ defaults:
+ _entity_form: 'workspace.deploy'
+ _title: 'Deploy Workspace'
+ options:
+ _admin_route: TRUE
+ requirements:
+ _permission: 'administer workspaces'
diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml
new file mode 100644
index 0000000..ed83dbe
--- /dev/null
+++ b/core/modules/workspace/workspace.services.yml
@@ -0,0 +1,27 @@
+services:
+ workspace.manager:
+ class: Drupal\workspace\WorkspaceManager
+ arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@logger.channel.workspace', '@class_resolver']
+ tags:
+ - { name: service_id_collector, tag: workspace_negotiator }
+ plugin.manager.workspace.repository_handler:
+ class: Drupal\workspace\RepositoryHandlerManager
+ parent: default_plugin_manager
+ workspace.negotiator.default:
+ class: Drupal\workspace\Negotiator\DefaultWorkspaceNegotiator
+ arguments: ['@entity_type.manager']
+ tags:
+ - { name: workspace_negotiator, priority: 0 }
+ workspace.negotiator.session:
+ class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator
+ arguments: ['@current_user', '@user.private_tempstore', '@entity_type.manager']
+ tags:
+ - { name: workspace_negotiator, priority: 100 }
+ cache_context.workspace:
+ class: Drupal\workspace\WorkspaceCacheContext
+ arguments: ['@workspace.manager']
+ tags:
+ - { name: cache.context }
+ logger.channel.workspace:
+ parent: logger.channel_base
+ arguments: ['cron']