');
+ $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\Tests\WebAssert $session */
+ $session = $this->assertSession();
+ $session->buttonExists('Activate');
+ $this->drupalPostForm(NULL, ['workspace_id' => $workspace->id()], 'Activate');
+ $session->pageTextContains($workspace->label() . ' is now the active workspace.');
+ }
+
+ /**
+ * 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\node\NodeInterface
+ * 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('Save')->click();
+ }
+ else {
+ $page->uncheckField('Published');
+ $page->findButton('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 0000000000000000000000000000000000000000..a0ed21bde46740d5bbc909290e8a3eae2c9ccdf4
--- /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 0000000000000000000000000000000000000000..e29f7b01d1ffbca4723377e11faeaa4e8776b599
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
@@ -0,0 +1,100 @@
+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);
+ $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/manage/{$bears->id()}/activate");
+ $this->assertSession()->statusCodeEquals(403);
+
+ // But editor 2 should be able to activate the Packers workspace.
+ $this->drupalGet("admin/config/workflow/workspace/manage/{$packers->id()}/activate");
+ $this->assertSession()->statusCodeEquals(200);
+ }
+
+ /**
+ * 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);
+ $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/manage/{$bears->id()}/activate");
+
+ $this->assertSession()->statusCodeEquals(200);
+
+ // But editor 2 should be able to activate the Packers workspace.
+ $this->drupalGet("admin/config/workflow/workspace/manage/{$packers->id()}/activate");
+ $this->assertSession()->statusCodeEquals(200);
+ }
+
+}
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 0000000000000000000000000000000000000000..7dd4626010e9517fbaf3ac076733dc6f22d55507
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
@@ -0,0 +1,85 @@
+installSchema('system', ['sequences']);
+
+ $this->installEntitySchema('workspace');
+ $this->installEntitySchema('workspace_association');
+ $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'],
+ ['view', 'view any workspace'],
+ ['view', 'view own workspace'],
+ ['update', 'edit any workspace'],
+ ['update', 'edit own workspace'],
+ ['delete', 'delete any workspace'],
+ ['delete', 'delete 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();
+
+ $this->assertFalse($workspace->access($operation, $user));
+
+ \Drupal::entityTypeManager()->getAccessControlHandler('workspace')->resetCache();
+ $role = $this->createRole([$permission]);
+ $user->addRole($role);
+ $this->assertTrue($workspace->access($operation, $user));
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..69c13ac057b6f9a10f7ddc4f8ad051e1634304a6
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php
@@ -0,0 +1,191 @@
+installSchema('system', ['key_value_expire', 'sequences']);
+ $this->installSchema('node', ['node_access']);
+
+ $this->installEntitySchema('workspace');
+ $this->installEntitySchema('workspace_association');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('user');
+
+ $this->installConfig(['filter', 'node', 'system']);
+
+ $this->createContentType(['type' => 'page']);
+
+ $this->entityTypeManager = \Drupal::entityTypeManager();
+ $this->state = \Drupal::state();
+ $this->workspaceManager = \Drupal::service('workspace.manager');
+ }
+
+ /**
+ * Tests the deletion of workspaces.
+ */
+ public function testDeletingWorkspaces() {
+ $admin = $this->createUser([
+ 'administer nodes',
+ 'create workspace',
+ 'view any workspace',
+ 'edit any workspace',
+ 'delete any workspace',
+ ]);
+ $this->setCurrentUser($admin);
+
+ /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+ /** @var \Drupal\node\NodeStorageInterface $node_storage */
+ $node_storage = $this->entityTypeManager->getStorage('node');
+
+ // Create a workspace with a very small number of associated node revisions.
+ $workspace_1 = Workspace::create([
+ 'id' => 'gibbon',
+ 'label' => 'Gibbon',
+ ]);
+ $workspace_1->save();
+ $this->workspaceManager->setActiveWorkspace($workspace_1);
+
+ $workspace_1_node_1 = $this->createNode(['status' => FALSE]);
+ $workspace_1_node_2 = $this->createNode(['status' => FALSE]);
+ for ($i = 0; $i < 4; $i++) {
+ $workspace_1_node_1->setNewRevision(TRUE);
+ $workspace_1_node_1->save();
+
+ $workspace_1_node_2->setNewRevision(TRUE);
+ $workspace_1_node_2->save();
+ }
+
+ // The workspace should have 10 associated node revisions, 5 for each node.
+ $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE);
+ $this->assertCount(10, $associated_revisions['node']);
+
+ // Check that we are allowed to delete the workspace.
+ $this->assertTrue($workspace_1->access('delete', $admin));
+
+ // Delete the workspace and check that all the workspace_association
+ // entities and all the node revisions have been deleted as well.
+ $workspace_1->delete();
+
+ $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE);
+ $this->assertCount(0, $associated_revisions);
+ $node_revision_count = $node_storage
+ ->getQuery()
+ ->allRevisions()
+ ->count()
+ ->execute();
+ $this->assertEquals(0, $node_revision_count);
+
+ // Create another workspace, this time with a larger number of associated
+ // node revisions so we can test the batch purge process.
+ $workspace_2 = Workspace::create([
+ 'id' => 'baboon',
+ 'label' => 'Baboon',
+ ]);
+ $workspace_2->save();
+ $this->workspaceManager->setActiveWorkspace($workspace_2);
+
+ $workspace_2_node_1 = $this->createNode(['status' => FALSE]);
+ for ($i = 0; $i < 59; $i++) {
+ $workspace_2_node_1->setNewRevision(TRUE);
+ $workspace_2_node_1->save();
+ }
+
+ // The workspace should have 60 associated node revisions.
+ $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE);
+ $this->assertCount(60, $associated_revisions['node']);
+
+ // Delete the workspace and check that we still have 10 revision left to
+ // delete.
+ $workspace_2->delete();
+
+ $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE);
+ $this->assertCount(10, $associated_revisions['node']);
+
+ $workspace_deleted = \Drupal::state()->get('workspace.deleted');
+ $this->assertCount(1, $workspace_deleted);
+
+ // Check that we can not create another workspace with the same ID while its
+ // data purging is not finished.
+ $workspace_3 = Workspace::create([
+ 'id' => 'baboon',
+ 'label' => 'Baboon',
+ ]);
+ $violations = $workspace_3->validate();
+ $this->assertCount(1, $violations);
+ $this->assertEquals('A workspace with this ID has been deleted but data still exists for it.', $violations[0]->getMessage());
+
+ // Running cron should delete the remaining data as well as the workspace ID
+ // from the "workspace.delete" state entry.
+ \Drupal::service('cron')->run();
+
+ $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE);
+ $this->assertCount(0, $associated_revisions);
+ $node_revision_count = $node_storage
+ ->getQuery()
+ ->allRevisions()
+ ->count()
+ ->execute();
+ $this->assertEquals(0, $node_revision_count);
+
+ $workspace_deleted = \Drupal::state()->get('workspace.deleted');
+ $this->assertCount(0, $workspace_deleted);
+ }
+
+}
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 0000000000000000000000000000000000000000..eae90d2ca7b42ee05a92a5d5aabd1d1fff69b144
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
@@ -0,0 +1,677 @@
+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('workspace_association');
+
+ // 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', 'target' => '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:
+ // - a multi-dimensional array keyed by the workspace ID, then by the entity
+ // ID and finally by the revision ID.
+ // - '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 and data entity tables.
+ $test_scenarios = [];
+
+ // The $expected_workspace_association array holds the revision IDs which
+ // should be tracked by the Workspace Association entity type in each test
+ // scenario, keyed by workspace ID.
+ $expected_workspace_association = [];
+
+ // 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;
+ $expected_workspace_association['initial_state'] = ['stage' => []];
+
+ // 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;
+ $expected_workspace_association['unpublish_node_1_in_stage'] = ['stage' => [3]];
+
+ // 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;
+ $expected_workspace_association['publish_node_2_in_stage'] = ['stage' => [3, 4]];
+
+ // 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;
+ $expected_workspace_association['add_unpublished_node_in_stage'] = ['stage' => [3, 4, 5]];
+
+ // 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;
+ $expected_workspace_association['add_published_node_in_stage'] = ['stage' => [3, 4, 5, 6, 7]];
+
+ // 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['push_stage_to_live'] = $revision_state;
+ $expected_workspace_association['push_stage_to_live'] = ['stage' => []];
+
+ // Check the initial state after the module was installed.
+ $this->assertWorkspaceStatus($test_scenarios['initial_state'], 'node');
+ $this->assertWorkspaceAssociation($expected_workspace_association['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');
+ $this->assertWorkspaceAssociation($expected_workspace_association['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');
+ $this->assertWorkspaceAssociation($expected_workspace_association['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');
+ $this->assertWorkspaceAssociation($expected_workspace_association['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');
+ $this->assertWorkspaceAssociation($expected_workspace_association['add_published_node_in_stage'], 'node');
+
+ // Deploy 'stage' to 'live'.
+ $stage_repository_handler = $this->workspaces['stage']->getRepositoryHandler();
+
+ // Check which revisions need to be pushed.
+ $expected = [
+ 'node' => [
+ 3 => 1,
+ 4 => 2,
+ 5 => 3,
+ 7 => 4,
+ ],
+ ];
+ $this->assertEquals($expected, $stage_repository_handler->getDifferringRevisionIdsOnSource());
+
+ $stage_repository_handler->push();
+ $this->assertWorkspaceStatus($test_scenarios['push_stage_to_live'], 'node');
+ $this->assertWorkspaceAssociation($expected_workspace_association['push_stage_to_live'], 'node');
+
+ // Check that there are no more revisions to push.
+ $this->assertEmpty($stage_repository_handler->getDifferringRevisionIdsOnSource());
+ }
+
+ /**
+ * 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\EntityInterface[]|\Drupal\Core\Entity\RevisionableInterface[]|\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\EntityInterface|\Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ $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\EntityInterface[]|\Drupal\Core\Entity\RevisionableInterface[]|\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'], (int) $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);
+ }
+ }
+
+ /**
+ * Checks the workspace_association entries 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 assertWorkspaceAssociation(array $expected, $entity_type_id) {
+ /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+ foreach ($expected as $workspace_id => $expected_tracked_revision_ids) {
+ $tracked_entities = $workspace_association_storage->getTrackedEntities($workspace_id, TRUE);
+ $tracked_revision_ids = isset($tracked_entities[$entity_type_id]) ? $tracked_entities[$entity_type_id] : [];
+ $this->assertEquals($expected_tracked_revision_ids, array_keys($tracked_revision_ids));
+ }
+ }
+
+ /**
+ * Sets a given workspace as active.
+ *
+ * @param string $workspace_id
+ * The ID of the workspace to switch to.
+ */
+ protected function switchToWorkspace($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 0000000000000000000000000000000000000000..1acf66f82958e825c410e043a5b1f27507402028
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
@@ -0,0 +1,42 @@
+setExpectedException(PluginNotFoundException::class, 'The "entity:workspace_association" plugin does not exist.');
+ RestResourceConfig::create([
+ 'id' => 'entity.workspace_association',
+ '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 0000000000000000000000000000000000000000..9599e07315455eb1fc65b0278c69df3e90e4e9b7
--- /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 0000000000000000000000000000000000000000..45cd59cbe6cc3da0602537e1f4a5b674518e9406
--- /dev/null
+++ b/core/modules/workspace/workspace.install
@@ -0,0 +1,46 @@
+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',
+ 'target' => '',
+ 'uid' => $owner_id,
+ ])->save();
+
+ Workspace::create([
+ 'id' => 'stage',
+ 'label' => 'Stage',
+ 'target' => '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 0000000000000000000000000000000000000000..c7aad42bb36f3caf41da3f7c964913b9a8778967
--- /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 0000000000000000000000000000000000000000..d5916034c8aa1b532449f94cccaf6fec0a763805
--- /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 0000000000000000000000000000000000000000..9f22598f307bb976b00e8d13afc7b30a8e7a9fae
--- /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 0000000000000000000000000000000000000000..c7faefbf6b82cd3b307d3e4af99083881536b264
--- /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 0000000000000000000000000000000000000000..dd2ef3823ad5eefbb8032e7cfabc8f1eb206e7be
--- /dev/null
+++ b/core/modules/workspace/workspace.module
@@ -0,0 +1,251 @@
+' . 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_type_build().
+ */
+function workspace_entity_type_build(array &$entity_types) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityTypeInfo::class)
+ ->entityTypeBuild($entity_types);
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function workspace_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityOperations::class)
+ ->formAlter($form, $form_state, $form_id);
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function workspace_entity_load(array &$entities, $entity_type_id) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityOperations::class)
+ ->entityLoad($entities, $entity_type_id);
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function workspace_entity_presave(EntityInterface $entity) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityOperations::class)
+ ->entityPresave($entity);
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function workspace_entity_insert(EntityInterface $entity) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityOperations::class)
+ ->entityInsert($entity);
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function workspace_entity_update(EntityInterface $entity) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(EntityOperations::class)
+ ->entityUpdate($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) {
+ return \Drupal::service('class_resolver')
+ ->getInstanceFromDefinition(ViewsQueryAlter::class)
+ ->alterQuery($view, $query);
+}
+
+/**
+ * Implements hook_cron().
+ */
+function workspace_cron() {
+ \Drupal::service('workspace.manager')->purgeDeletedWorkspacesBatch();
+}
+
+/**
+ * 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_build_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'][] = 'workspace-toolbar-tab--is-default';
+ }
+
+ 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_build_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 0000000000000000000000000000000000000000..4856449f7087fed1874b56302f28cc510a47afbe
--- /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
+
+delete own workspace:
+ title: Delete own workspace
+
+delete any workspace:
+ title: Delete 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
diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..70a6a22987a98826375b84ba83ec6a199ee99136
--- /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/manage/{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/manage/{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 0000000000000000000000000000000000000000..13d67aee5b6a463532cfd0c17485045788ac8d96
--- /dev/null
+++ b/core/modules/workspace/workspace.services.yml
@@ -0,0 +1,41 @@
+services:
+ workspace.manager:
+ class: Drupal\workspace\WorkspaceManager
+ arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@state', '@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', '@session', '@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: ['workspace']
+ workspace.entity.query.sql:
+ decorates: 'entity.query.sql'
+ class: Drupal\workspace\EntityQuery\QueryFactory
+ arguments: ['@database', '@workspace.manager']
+ public: false
+ decoration_priority: 50
+ tags:
+ - { name: backend_overridable }
+ pgsql.workspace.entity.query.sql:
+ decorates: 'pgsql.entity.query.sql'
+ class: Drupal\workspace\EntityQuery\PgsqlQueryFactory
+ arguments: ['@database', '@workspace.manager']
+ public: false
+ decoration_priority: 50