summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNathaniel Catchpole2018-05-04 12:13:32 (GMT)
committerNathaniel Catchpole2018-05-04 12:13:32 (GMT)
commit4ecf7cb1cbb9b048a434c713e86200cba2c2d06f (patch)
tree6ebfb618452ee1e07ab9e1230816e61e5882648f
parentada497ac26f833eaa9f227edff6a919ec5da6252 (diff)
Issue #2784921 by amateescu, timmillwood, pk188, plach, catch, Fabianx, dixon_, dawehner, borisson_, Sam152, yoroy, webchick, phenaproxima, larowlan: Add Workspaces experimental module
-rw-r--r--core/composer.json3
-rw-r--r--core/modules/config/tests/src/Functional/ConfigImportAllTest.php5
-rw-r--r--core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php7
-rw-r--r--core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml14
-rw-r--r--core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml9
-rw-r--r--core/modules/workspace/css/workspace.toolbar.css54
-rw-r--r--core/modules/workspace/icons/ffffff/workspace.svg1
-rw-r--r--core/modules/workspace/src/Annotation/RepositoryHandler.php53
-rw-r--r--core/modules/workspace/src/Entity/Workspace.php222
-rw-r--r--core/modules/workspace/src/Entity/WorkspaceAssociation.php78
-rw-r--r--core/modules/workspace/src/EntityAccess.php131
-rw-r--r--core/modules/workspace/src/EntityOperations.php295
-rw-r--r--core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php53
-rw-r--r--core/modules/workspace/src/EntityQuery/Query.php62
-rw-r--r--core/modules/workspace/src/EntityQuery/QueryAggregate.php31
-rw-r--r--core/modules/workspace/src/EntityQuery/QueryFactory.php53
-rw-r--r--core/modules/workspace/src/EntityQuery/QueryTrait.php72
-rw-r--r--core/modules/workspace/src/EntityQuery/Tables.php155
-rw-r--r--core/modules/workspace/src/EntityTypeInfo.php73
-rw-r--r--core/modules/workspace/src/Form/WorkspaceActivateForm.php117
-rw-r--r--core/modules/workspace/src/Form/WorkspaceDeleteForm.php49
-rw-r--r--core/modules/workspace/src/Form/WorkspaceDeployForm.php193
-rw-r--r--core/modules/workspace/src/Form/WorkspaceForm.php158
-rw-r--r--core/modules/workspace/src/Form/WorkspaceSwitcherForm.php132
-rw-r--r--core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php69
-rw-r--r--core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php81
-rw-r--r--core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php50
-rw-r--r--core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php84
-rw-r--r--core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php217
-rw-r--r--core/modules/workspace/src/Plugin/RepositoryHandler/NullRepositoryHandler.php82
-rw-r--r--core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php24
-rw-r--r--core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php64
-rw-r--r--core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php25
-rw-r--r--core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php77
-rw-r--r--core/modules/workspace/src/RepositoryHandlerBase.php72
-rw-r--r--core/modules/workspace/src/RepositoryHandlerInterface.php127
-rw-r--r--core/modules/workspace/src/RepositoryHandlerManager.php66
-rw-r--r--core/modules/workspace/src/RepositoryHandlerManagerInterface.php23
-rw-r--r--core/modules/workspace/src/ViewsQueryAlter.php422
-rw-r--r--core/modules/workspace/src/WorkspaceAccessControlHandler.php58
-rw-r--r--core/modules/workspace/src/WorkspaceAccessException.php12
-rw-r--r--core/modules/workspace/src/WorkspaceAssociationStorage.php59
-rw-r--r--core/modules/workspace/src/WorkspaceAssociationStorageInterface.php48
-rw-r--r--core/modules/workspace/src/WorkspaceCacheContext.php53
-rw-r--r--core/modules/workspace/src/WorkspaceConflictException.php10
-rw-r--r--core/modules/workspace/src/WorkspaceInterface.php63
-rw-r--r--core/modules/workspace/src/WorkspaceListBuilder.php110
-rw-r--r--core/modules/workspace/src/WorkspaceManager.php283
-rw-r--r--core/modules/workspace/src/WorkspaceManagerInterface.php64
-rw-r--r--core/modules/workspace/src/WorkspaceServiceProvider.php23
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php26
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php36
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php31
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php210
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php36
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php46
-rw-r--r--core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php41
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php64
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php84
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php97
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php209
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php51
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceTest.php112
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php156
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php41
-rw-r--r--core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php100
-rw-r--r--core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php85
-rw-r--r--core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php191
-rw-r--r--core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php677
-rw-r--r--core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php42
-rw-r--r--core/modules/workspace/workspace.info.yml9
-rw-r--r--core/modules/workspace/workspace.install46
-rw-r--r--core/modules/workspace/workspace.libraries.yml5
-rw-r--r--core/modules/workspace/workspace.link_relation_types.yml8
-rw-r--r--core/modules/workspace/workspace.links.action.yml5
-rw-r--r--core/modules/workspace/workspace.links.menu.yml5
-rw-r--r--core/modules/workspace/workspace.module251
-rw-r--r--core/modules/workspace/workspace.permissions.yml28
-rw-r--r--core/modules/workspace/workspace.routing.yml27
-rw-r--r--core/modules/workspace/workspace.services.yml41
80 files changed, 7045 insertions, 1 deletions
diff --git a/core/composer.json b/core/composer.json
index a2d59b6..7452a07 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -167,7 +167,8 @@
"drupal/user": "self.version",
"drupal/views": "self.version",
"drupal/views_ui": "self.version",
- "drupal/workflows": "self.version"
+ "drupal/workflows": "self.version",
+ "drupal/workspace": "self.version"
},
"extra": {
"merge-plugin": {
diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
index 4823e6b..f2201ea 100644
--- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
+++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
@@ -8,6 +8,7 @@ use Drupal\shortcut\Entity\Shortcut;
use Drupal\taxonomy\Entity\Term;
use Drupal\Tests\SchemaCheckTestTrait;
use Drupal\Tests\system\Functional\Module\ModuleTestBase;
+use Drupal\workspace\Entity\Workspace;
/**
* Tests the largest configuration import possible with all available modules.
@@ -93,6 +94,10 @@ class ConfigImportAllTest extends ModuleTestBase {
$shortcuts = Shortcut::loadMultiple();
entity_delete_multiple('shortcut', array_keys($shortcuts));
+ // Delete any workspaces so the workspace module can be uninstalled.
+ $workspaces = Workspace::loadMultiple();
+ \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+
system_list_reset();
$all_modules = system_rebuild_module_data();
diff --git a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
index c3fcb75..ea03889 100644
--- a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
+++ b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
@@ -4,6 +4,7 @@ namespace Drupal\Tests\system\Functional\Module;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\workspace\Entity\Workspace;
/**
* Install/uninstall core module and confirm table creation/deletion.
@@ -148,6 +149,12 @@ class InstallUninstallTest extends ModuleTestBase {
$this->preUninstallForum();
}
+ // Delete all workspaces before uninstall.
+ if ($name == 'workspace') {
+ $workspaces = Workspace::loadMultiple();
+ \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+ }
+
$now_installed_list = \Drupal::moduleHandler()->getModuleList();
$added_modules = array_diff(array_keys($now_installed_list), array_keys($was_installed_list));
while ($added_modules) {
diff --git a/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
new file mode 100644
index 0000000..271e185
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
@@ -0,0 +1,14 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_form_mode.workspace.deploy
+ module:
+ - workspace
+id: workspace.workspace.deploy
+targetEntityType: workspace
+bundle: workspace
+mode: deploy
+content: { }
+hidden:
+ uid: true
diff --git a/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
new file mode 100644
index 0000000..1e02853
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - workspace
+id: workspace.deploy
+label: Deploy
+targetEntityType: workspace
+cache: true
diff --git a/core/modules/workspace/css/workspace.toolbar.css b/core/modules/workspace/css/workspace.toolbar.css
new file mode 100644
index 0000000..9c69720
--- /dev/null
+++ b/core/modules/workspace/css/workspace.toolbar.css
@@ -0,0 +1,54 @@
+/**
+ * @file
+ * Styling for Workspace module's toolbar tab.
+ */
+
+/* Tab appearance. */
+.toolbar .toolbar-bar .workspace-toolbar-tab {
+ float: right; /* LTR */
+ background-color: #e09600;
+}
+[dir="rtl"] .toolbar .toolbar-bar .workspace-toolbar-tab {
+ float: left;
+}
+.toolbar .toolbar-bar .workspace-toolbar-tab--is-default {
+ background-color: #77b259;
+}
+
+.toolbar .toolbar-bar .workspace-toolbar-tab .toolbar-item {
+ margin: 0;
+}
+
+.toolbar .toolbar-icon-workspace:before {
+ background-image: url("../icons/ffffff/workspace.svg");
+}
+
+/* Manage workspaces link */
+.toolbar .toolbar-tray-vertical .manage-workspaces {
+ text-align: right; /* LTR */
+ padding: 1em;
+}
+[dir="rtl"] .toolbar .toolbar-tray-vertical .manage-workspaces {
+ text-align: left;
+}
+.toolbar .toolbar-tray-horizontal .manage-workspaces {
+ float: right; /* LTR */
+}
+[dir="rtl"] .toolbar .toolbar-tray-horizontal .manage-workspaces {
+ float: left;
+}
+
+/* Individual workspace links */
+.toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+ border-left: 1px solid #ddd; /* LTR */
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+ border-left: 0 none;
+ border-right: 1px solid #ddd;
+}
+.toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+ border-right: 1px solid #ddd;
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+ border-left: 1px solid #ddd;
+}
diff --git a/core/modules/workspace/icons/ffffff/workspace.svg b/core/modules/workspace/icons/ffffff/workspace.svg
new file mode 100644
index 0000000..299ff26
--- /dev/null
+++ b/core/modules/workspace/icons/ffffff/workspace.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><g fill="#FFF"><path d="M14 12L16 12 16 0 4 0 4 2 14 2 14 12ZM0 4L12 4 12 16 0 16 0 4Z"/></g></g></svg>
diff --git a/core/modules/workspace/src/Annotation/RepositoryHandler.php b/core/modules/workspace/src/Annotation/RepositoryHandler.php
new file mode 100644
index 0000000..89f7010
--- /dev/null
+++ b/core/modules/workspace/src/Annotation/RepositoryHandler.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a RepositoryHandler annotation object.
+ *
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see \Drupal\workspace\RepositoryHandlerBase
+ * @see \Drupal\workspace\RepositoryHandlerManager
+ * @see plugin_api
+ *
+ * @Annotation
+ */
+class RepositoryHandler extends Plugin {
+
+ /**
+ * The plugin ID.
+ *
+ * @var string
+ */
+ public $id;
+
+ /**
+ * The human-readable name of the repository handler plugin.
+ *
+ * @var \Drupal\Core\Annotation\Translation
+ *
+ * @ingroup plugin_translatable
+ */
+ public $label;
+
+ /**
+ * A short description of the repository handler plugin.
+ *
+ * @var \Drupal\Core\Annotation\Translation
+ *
+ * @ingroup plugin_translatable
+ */
+ public $description;
+
+ /**
+ * The human-readable category.
+ *
+ * @var \Drupal\Core\Annotation\Translation
+ *
+ * @ingroup plugin_translatable
+ */
+ public $category = '';
+
+}
diff --git a/core/modules/workspace/src/Entity/Workspace.php b/core/modules/workspace/src/Entity/Workspace.php
new file mode 100644
index 0000000..beba88a
--- /dev/null
+++ b/core/modules/workspace/src/Entity/Workspace.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityChangedTrait;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\user\UserInterface;
+use Drupal\workspace\WorkspaceInterface;
+
+/**
+ * The workspace entity class.
+ *
+ * @ContentEntityType(
+ * id = "workspace",
+ * label = @Translation("Workspace"),
+ * label_collection = @Translation("Workspaces"),
+ * label_singular = @Translation("workspace"),
+ * label_plural = @Translation("workspaces"),
+ * label_count = @PluralTranslation(
+ * singular = "@count workspace",
+ * plural = "@count workspaces"
+ * ),
+ * handlers = {
+ * "list_builder" = "\Drupal\workspace\WorkspaceListBuilder",
+ * "access" = "Drupal\workspace\WorkspaceAccessControlHandler",
+ * "route_provider" = {
+ * "html" = "\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ * },
+ * "form" = {
+ * "default" = "\Drupal\workspace\Form\WorkspaceForm",
+ * "add" = "\Drupal\workspace\Form\WorkspaceForm",
+ * "edit" = "\Drupal\workspace\Form\WorkspaceForm",
+ * "delete" = "\Drupal\workspace\Form\WorkspaceDeleteForm",
+ * "activate" = "\Drupal\workspace\Form\WorkspaceActivateForm",
+ * "deploy" = "\Drupal\workspace\Form\WorkspaceDeployForm",
+ * },
+ * },
+ * admin_permission = "administer workspaces",
+ * base_table = "workspace",
+ * revision_table = "workspace_revision",
+ * data_table = "workspace_field_data",
+ * revision_data_table = "workspace_field_revision",
+ * entity_keys = {
+ * "id" = "id",
+ * "revision" = "revision_id",
+ * "uuid" = "uuid",
+ * "label" = "label",
+ * "uid" = "uid",
+ * },
+ * links = {
+ * "add-form" = "/admin/config/workflow/workspace/add",
+ * "edit-form" = "/admin/config/workflow/workspace/manage/{workspace}/edit",
+ * "delete-form" = "/admin/config/workflow/workspace/manage/{workspace}/delete",
+ * "activate-form" = "/admin/config/workflow/workspace/manage/{workspace}/activate",
+ * "deploy-form" = "/admin/config/workflow/workspace/manage/{workspace}/deploy",
+ * "collection" = "/admin/config/workflow/workspace",
+ * },
+ * )
+ */
+class Workspace extends ContentEntityBase implements WorkspaceInterface {
+
+ use EntityChangedTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+ $fields = parent::baseFieldDefinitions($entity_type);
+
+ $fields['id'] = BaseFieldDefinition::create('string')
+ ->setLabel(new TranslatableMarkup('Workspace ID'))
+ ->setDescription(new TranslatableMarkup('The workspace ID.'))
+ ->setSetting('max_length', 128)
+ ->setRequired(TRUE)
+ ->addConstraint('UniqueField')
+ ->addConstraint('DeletedWorkspace')
+ ->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[a-z0-9_]+$/']]);
+
+ $fields['label'] = BaseFieldDefinition::create('string')
+ ->setLabel(new TranslatableMarkup('Workspace name'))
+ ->setDescription(new TranslatableMarkup('The workspace name.'))
+ ->setRevisionable(TRUE)
+ ->setSetting('max_length', 128)
+ ->setRequired(TRUE);
+
+ $fields['uid'] = BaseFieldDefinition::create('entity_reference')
+ ->setLabel(new TranslatableMarkup('Owner'))
+ ->setDescription(new TranslatableMarkup('The workspace owner.'))
+ ->setRevisionable(TRUE)
+ ->setSetting('target_type', 'user')
+ ->setDefaultValueCallback('Drupal\workspace\Entity\Workspace::getCurrentUserId')
+ ->setDisplayOptions('form', [
+ 'type' => 'entity_reference_autocomplete',
+ 'weight' => 5,
+ ])
+ ->setDisplayConfigurable('form', TRUE);
+
+ $fields['changed'] = BaseFieldDefinition::create('changed')
+ ->setLabel(new TranslatableMarkup('Changed'))
+ ->setDescription(new TranslatableMarkup('The time that the workspace was last edited.'))
+ ->setRevisionable(TRUE);
+
+ $fields['created'] = BaseFieldDefinition::create('created')
+ ->setLabel(new TranslatableMarkup('Created'))
+ ->setDescription(new TranslatableMarkup('The time that the workspaces was created.'));
+
+ $fields['target'] = BaseFieldDefinition::create('string')
+ ->setLabel(new TranslatableMarkup('Target workspace'))
+ ->setDescription(new TranslatableMarkup('The workspace to push to and pull from.'))
+ ->setRevisionable(TRUE)
+ ->setRequired(TRUE)
+ ->setDefaultValue('live');
+
+ return $fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function push() {
+ return $this->getRepositoryHandler()->push();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function pull() {
+ return $this->getRepositoryHandler()->pull();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRepositoryHandler() {
+ return \Drupal::service('plugin.manager.workspace.repository_handler')->createFromWorkspace($this);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isDefaultWorkspace() {
+ return $this->id() === static::DEFAULT_WORKSPACE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCreatedTime() {
+ return $this->get('created')->value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCreatedTime($created) {
+ return $this->set('created', (int) $created);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOwner() {
+ return $this->get('uid')->entity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOwner(UserInterface $account) {
+ return $this->set('uid', $account->id());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOwnerId() {
+ return $this->get('uid')->target_id;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setOwnerId($uid) {
+ return $this->set('uid', $uid);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function postDelete(EntityStorageInterface $storage, array $entities) {
+ parent::postDelete($storage, $entities);
+
+ // Add the IDs of the deleted workspaces to the list of workspaces that will
+ // be purged on cron.
+ $state = \Drupal::state();
+ $deleted_workspace_ids = $state->get('workspace.deleted', []);
+ unset($entities[static::DEFAULT_WORKSPACE]);
+ $deleted_workspace_ids += array_combine(array_keys($entities), array_keys($entities));
+ $state->set('workspace.deleted', $deleted_workspace_ids);
+
+ // Trigger a batch purge to allow empty workspaces to be deleted
+ // immediately.
+ \Drupal::service('workspace.manager')->purgeDeletedWorkspacesBatch();
+ }
+
+ /**
+ * Default value callback for 'uid' base field definition.
+ *
+ * @see ::baseFieldDefinitions()
+ *
+ * @return int[]
+ * An array containing the ID of the current user.
+ */
+ public static function getCurrentUserId() {
+ return [\Drupal::currentUser()->id()];
+ }
+
+}
diff --git a/core/modules/workspace/src/Entity/WorkspaceAssociation.php b/core/modules/workspace/src/Entity/WorkspaceAssociation.php
new file mode 100644
index 0000000..d49a5f4
--- /dev/null
+++ b/core/modules/workspace/src/Entity/WorkspaceAssociation.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Defines the Workspace association entity.
+ *
+ * @ContentEntityType(
+ * id = "workspace_association",
+ * label = @Translation("Workspace association"),
+ * label_collection = @Translation("Workspace associations"),
+ * label_singular = @Translation("workspace association"),
+ * label_plural = @Translation("workspace associations"),
+ * label_count = @PluralTranslation(
+ * singular = "@count workspace association",
+ * plural = "@count workspace associations"
+ * ),
+ * handlers = {
+ * "storage" = "Drupal\workspace\WorkspaceAssociationStorage"
+ * },
+ * base_table = "workspace_association",
+ * revision_table = "workspace_association_revision",
+ * internal = TRUE,
+ * entity_keys = {
+ * "id" = "id",
+ * "revision" = "revision_id",
+ * "uuid" = "uuid",
+ * }
+ * )
+ *
+ * @internal
+ * This entity is marked internal because it should not be used directly to
+ * alter the workspace an entity belongs to.
+ */
+class WorkspaceAssociation extends ContentEntityBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+ $fields = parent::baseFieldDefinitions($entity_type);
+
+ $fields['workspace'] = BaseFieldDefinition::create('entity_reference')
+ ->setLabel(new TranslatableMarkup('workspace'))
+ ->setDescription(new TranslatableMarkup('The workspace of the referenced content.'))
+ ->setSetting('target_type', 'workspace')
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE)
+ ->addConstraint('workspace', []);
+
+ $fields['target_entity_type_id'] = BaseFieldDefinition::create('string')
+ ->setLabel(new TranslatableMarkup('Content entity type ID'))
+ ->setDescription(new TranslatableMarkup('The ID of the content entity type associated with this workspace.'))
+ ->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ $fields['target_entity_id'] = BaseFieldDefinition::create('integer')
+ ->setLabel(new TranslatableMarkup('Content entity ID'))
+ ->setDescription(new TranslatableMarkup('The ID of the content entity associated with this workspace.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ $fields['target_entity_revision_id'] = BaseFieldDefinition::create('integer')
+ ->setLabel(new TranslatableMarkup('Content entity revision ID'))
+ ->setDescription(new TranslatableMarkup('The revision ID of the content entity associated with this workspace.'))
+ ->setRequired(TRUE)
+ ->setRevisionable(TRUE);
+
+ return $fields;
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityAccess.php b/core/modules/workspace/src/EntityAccess.php
new file mode 100644
index 0000000..98432dc
--- /dev/null
+++ b/core/modules/workspace/src/EntityAccess.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Service wrapper for hooks relating to entity access control.
+ *
+ * @internal
+ */
+class EntityAccess implements ContainerInjectionInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * The entity type manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a new EntityAccess instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('workspace.manager')
+ );
+ }
+
+ /**
+ * Implements a hook bridge for hook_entity_access().
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity to check access for.
+ * @param string $operation
+ * The operation being performed.
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The user account making the to check access for.
+ *
+ * @return \Drupal\Core\Access\AccessResult
+ * The result of the access check.
+ *
+ * @see hook_entity_access()
+ */
+ public function entityOperationAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+ // Workspaces themselves are handled by their own access handler and we
+ // should not try to do any access checks for entity types that can not
+ // belong to a workspace.
+ if ($entity->getEntityTypeId() === 'workspace' || !$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) {
+ return AccessResult::neutral();
+ }
+
+ return $this->bypassAccessResult($account);
+ }
+
+ /**
+ * Implements a hook bridge for hook_entity_create_access().
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The user account making the to check access for.
+ * @param array $context
+ * The context of the access check.
+ * @param string $entity_bundle
+ * The bundle of the entity.
+ *
+ * @return \Drupal\Core\Access\AccessResult
+ * The result of the access check.
+ *
+ * @see hook_entity_create_access()
+ */
+ public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) {
+ // Workspaces themselves are handled by their own access handler and we
+ // should not try to do any access checks for entity types that can not
+ // belong to a workspace.
+ $entity_type = $this->entityTypeManager->getDefinition($context['entity_type_id']);
+ if ($entity_type->id() === 'workspace' || !$this->workspaceManager->isEntityTypeSupported($entity_type)) {
+ return AccessResult::neutral();
+ }
+
+ return $this->bypassAccessResult($account);
+ }
+
+ /**
+ * Checks the 'bypass' permissions.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The user account making the to check access for.
+ *
+ * @return \Drupal\Core\Access\AccessResult
+ * The result of the access check.
+ */
+ protected function bypassAccessResult(AccountInterface $account) {
+ // This approach assumes that the current "global" active workspace is
+ // correct, i.e. if you're "in" a given workspace then you get ALL THE PERMS
+ // to ALL THE THINGS! That's why this is a dangerous permission.
+ $active_workspace = $this->workspaceManager->getActiveWorkspace();
+
+ return AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id())->cachePerUser()->addCacheableDependency($active_workspace)
+ ->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace'));
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityOperations.php b/core/modules/workspace/src/EntityOperations.php
new file mode 100644
index 0000000..24887ad
--- /dev/null
+++ b/core/modules/workspace/src/EntityOperations.php
@@ -0,0 +1,295 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for reacting to entity events.
+ *
+ * @internal
+ */
+class EntityOperations implements ContainerInjectionInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * The entity type manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a new EntityOperations instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('workspace.manager')
+ );
+ }
+
+ /**
+ * Acts on entities when loaded.
+ *
+ * @see hook_entity_load()
+ */
+ public function entityLoad(array &$entities, $entity_type_id) {
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$this->workspaceManager->shouldAlterOperations($this->entityTypeManager->getDefinition($entity_type_id))) {
+ 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_target_entity_revision_id';
+ $results = $this->entityTypeManager
+ ->getStorage('workspace_association')
+ ->getAggregateQuery()
+ ->accessCheck(FALSE)
+ ->allRevisions()
+ ->aggregate('target_entity_revision_id', 'MAX', NULL, $max_revision_id)
+ ->groupBy('target_entity_id')
+ ->condition('target_entity_type_id', $entity_type_id)
+ ->condition('target_entity_id', $entity_ids, 'IN')
+ ->condition('workspace', $this->workspaceManager->getActiveWorkspace()->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) {
+ $results = array_filter($results, function ($result) use ($entities, $max_revision_id) {
+ return $entities[$result['target_entity_id']]->getRevisionId() != $result[$max_revision_id];
+ });
+ }
+
+ if ($results) {
+ /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
+ $storage = $this->entityTypeManager->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;
+ }
+ }
+ }
+
+ /**
+ * Acts on an entity before it is created or updated.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity being saved.
+ *
+ * @see hook_entity_presave()
+ */
+ public function entityPresave(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) {
+ return;
+ }
+
+ if (!$entity->isNew() && !isset($entity->_isReplicating)) {
+ // Force a new revision if the entity is not replicating.
+ $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.
+ // @todo Remove this dynamic property once we have an API for associating
+ // temporary data with an entity: https://www.drupal.org/node/2896474.
+ $entity->_initialPublished = TRUE;
+ $entity->setUnpublished();
+ }
+ }
+
+ /**
+ * Responds to the creation of a new entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity that was just saved.
+ *
+ * @see hook_entity_insert()
+ */
+ public function entityInsert(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) {
+ return;
+ }
+
+ $this->trackEntity($entity);
+
+ // Handle the case when a new published entity was created in a non-default
+ // workspace and create a published pending revision for it. This does not
+ // cause an infinite recursion with ::entityPresave() because at this point
+ // the entity is no longer new.
+ // @todo Better explain in https://www.drupal.org/node/2962764
+ 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();
+ }
+ }
+
+ /**
+ * Responds to updates to an entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity that was just saved.
+ *
+ * @see hook_entity_update()
+ */
+ public function entityUpdate(EntityInterface $entity) {
+ // Only run if the entity type can belong to a workspace and we are in a
+ // non-default workspace.
+ if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) {
+ return;
+ }
+
+ // Only track new revisions.
+ /** @var \Drupal\Core\Entity\RevisionableInterface $entity */
+ if ($entity->getLoadedRevisionId() != $entity->getRevisionId()) {
+ $this->trackEntity($entity);
+ }
+ }
+
+ /**
+ * Updates or creates a WorkspaceAssociation entity for a given entity.
+ *
+ * If the passed-in entity can belong to a workspace and already has a
+ * WorkspaceAssociation entity, then a new revision of this will be created with
+ * the new information. Otherwise, a new WorkspaceAssociation entity is created to
+ * store the passed-in entity's information.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity to update or create from.
+ */
+ protected function trackEntity(EntityInterface $entity) {
+ /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+ // If the entity is not new, check if there's an existing
+ // WorkspaceAssociation entity for it.
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+ if (!$entity->isNew()) {
+ $workspace_associations = $workspace_association_storage->loadByProperties([
+ 'target_entity_type_id' => $entity->getEntityTypeId(),
+ 'target_entity_id' => $entity->id(),
+ ]);
+
+ /** @var \Drupal\Core\Entity\ContentEntityInterface $workspace_association */
+ $workspace_association = reset($workspace_associations);
+ }
+
+ // If there was a WorkspaceAssociation entry create a new revision,
+ // otherwise create a new entity with the type and ID.
+ if (!empty($workspace_association)) {
+ $workspace_association->setNewRevision(TRUE);
+ }
+ else {
+ $workspace_association = $workspace_association_storage->create([
+ 'target_entity_type_id' => $entity->getEntityTypeId(),
+ 'target_entity_id' => $entity->id(),
+ ]);
+ }
+
+ // Add the revision ID and the workspace ID.
+ $workspace_association->set('target_entity_revision_id', $entity->getRevisionId());
+ $workspace_association->set('workspace', $this->workspaceManager->getActiveWorkspace()->id());
+
+ // Save without updating the tracked content entity.
+ $workspace_association->save();
+ }
+
+ /**
+ * Alters entity forms to disallow concurrent editing in multiple workspaces.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ * @param string $form_id
+ * The form ID.
+ *
+ * @see hook_form_alter()
+ */
+ public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form_object = $form_state->getFormObject();
+ if (!$form_object instanceof EntityFormInterface) {
+ return;
+ }
+
+ $entity = $form_object->getEntity();
+ if (!$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) {
+ return;
+ }
+
+ /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+ if ($workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity)) {
+ // An entity can only be edited in one workspace.
+ $workspace_id = reset($workspace_ids);
+
+ if ($workspace_id !== $this->workspaceManager->getActiveWorkspace()->id()) {
+ $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+
+ $form['#markup'] = $this->t('The content is being edited in the %label workspace.', ['%label' => $workspace->label()]);
+ $form['#access'] = FALSE;
+ }
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php
new file mode 100644
index 0000000..c045dd0
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryBase;
+use Drupal\Core\Entity\Query\Sql\pgsql\QueryFactory as BaseQueryFactory;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Workspace PostgreSQL specific entity query implementation.
+ */
+class PgsqlQueryFactory extends BaseQueryFactory {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a PgsqlQueryFactory object.
+ *
+ * @param \Drupal\Core\Database\Connection $connection
+ * The database connection used by the entity query.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ */
+ public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
+ $this->connection = $connection;
+ $this->workspaceManager = $workspace_manager;
+ $this->namespaces = QueryBase::getNamespaces($this);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get(EntityTypeInterface $entity_type, $conjunction) {
+ $class = QueryBase::getClass($this->namespaces, 'Query');
+ return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
+ $class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
+ return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Query.php b/core/modules/workspace/src/EntityQuery/Query.php
new file mode 100644
index 0000000..30cdcf2
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Query.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\Query as BaseQuery;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Query extends BaseQuery {
+
+ use QueryTrait {
+ prepare as traitPrepare;
+ }
+
+ /**
+ * Stores the SQL expressions used to build the SQL query.
+ *
+ * The array is keyed by the expression alias and the values are the actual
+ * expressions.
+ *
+ * @var array
+ * An array of expressions.
+ */
+ protected $sqlExpressions = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepare() {
+ $this->traitPrepare();
+
+ // If the prepare() method from the trait decided that we need to alter this
+ // query, we need to re-define the the key fields for fetchAllKeyed() as SQL
+ // expressions.
+ if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+ $id_field = $this->entityType->getKey('id');
+ $revision_field = $this->entityType->getKey('revision');
+
+ // Since the query is against the base table, we have to take into account
+ // that the revision ID might come from the workspace_association
+ // relationship, and, as a consequence, the revision ID field is no longer
+ // a simple SQL field but an expression.
+ $this->sqlFields = [];
+ $this->sqlExpressions[$revision_field] = "COALESCE(workspace_association.target_entity_revision_id, base_table.$revision_field)";
+ $this->sqlExpressions[$id_field] = "base_table.$id_field";
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function finish() {
+ foreach ($this->sqlExpressions as $alias => $expression) {
+ $this->sqlQuery->addExpression($expression, $alias);
+ }
+ return parent::finish();
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryAggregate.php b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
new file mode 100644
index 0000000..3a1f181
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\QueryAggregate as BaseQueryAggregate;
+
+/**
+ * Alters aggregate entity queries to use a workspace revision if possible.
+ */
+class QueryAggregate extends BaseQueryAggregate {
+
+ use QueryTrait {
+ prepare as traitPrepare;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepare() {
+ // Aggregate entity queries do not return an array of entity IDs keyed by
+ // revision IDs, they only return the values of the aggregated fields, so we
+ // don't need to add any expressions like we do in
+ // \Drupal\workspace\EntityQuery\Query::prepare().
+ $this->traitPrepare();
+
+ // Throw away the ID fields.
+ $this->sqlFields = [];
+ return $this;
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryFactory.php b/core/modules/workspace/src/EntityQuery/QueryFactory.php
new file mode 100644
index 0000000..e0a9202
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryFactory.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryBase;
+use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Workspace specific entity query implementation.
+ */
+class QueryFactory extends BaseQueryFactory {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a QueryFactory object.
+ *
+ * @param \Drupal\Core\Database\Connection $connection
+ * The database connection used by the entity query.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ */
+ public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
+ $this->connection = $connection;
+ $this->workspaceManager = $workspace_manager;
+ $this->namespaces = QueryBase::getNamespaces($this);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get(EntityTypeInterface $entity_type, $conjunction) {
+ $class = QueryBase::getClass($this->namespaces, 'Query');
+ return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
+ $class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
+ return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryTrait.php b/core/modules/workspace/src/EntityQuery/QueryTrait.php
new file mode 100644
index 0000000..5d34686
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryTrait.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Provides workspace-specific helpers for altering entity queries.
+ */
+trait QueryTrait {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a Query object.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition.
+ * @param string $conjunction
+ * - AND: all of the conditions on the query need to match.
+ * - OR: at least one of the conditions on the query need to match.
+ * @param \Drupal\Core\Database\Connection $connection
+ * The database connection to run the query against.
+ * @param array $namespaces
+ * List of potential namespaces of the classes belonging to this query.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ */
+ public function __construct(EntityTypeInterface $entity_type, $conjunction, Connection $connection, array $namespaces, WorkspaceManagerInterface $workspace_manager) {
+ parent::__construct($entity_type, $conjunction, $connection, $namespaces);
+
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepare() {
+ parent::prepare();
+
+ // Do not alter entity revision queries.
+ // @todo How about queries for the latest revision? Should we alter them to
+ // look for the latest workspace-specific revision?
+ if ($this->allRevisions) {
+ return $this;
+ }
+
+ // Only alter the query if the active workspace is not the default one and
+ // the entity type is supported.
+ $active_workspace = $this->workspaceManager->getActiveWorkspace();
+ if (!$active_workspace->isDefaultWorkspace() && $this->workspaceManager->isEntityTypeSupported($this->entityType)) {
+ $this->sqlQuery->addMetaData('active_workspace_id', $active_workspace->id());
+ $this->sqlQuery->addMetaData('simple_query', FALSE);
+
+ // LEFT JOIN 'workspace_association' to the base table of the query so we
+ // can properly include live content along with a possible workspace
+ // revision.
+ $id_field = $this->entityType->getKey('id');
+ $this->sqlQuery->leftJoin('workspace_association', 'workspace_association', "%alias.target_entity_type_id = '{$this->entityTypeId}' AND %alias.target_entity_id = base_table.$id_field AND %alias.workspace = '{$active_workspace->id()}'");
+ }
+
+ return $this;
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Tables.php b/core/modules/workspace/src/EntityQuery/Tables.php
new file mode 100644
index 0000000..55fc04f
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Tables.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Query\SelectInterface;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Entity\Query\Sql\Tables as BaseTables;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Tables extends BaseTables {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Workspace association table array, key is base table name, value is alias.
+ *
+ * @var array
+ */
+ protected $contentWorkspaceTables = [];
+
+ /**
+ * Keeps track of the entity type IDs for each base table of the query.
+ *
+ * The array is keyed by the base table alias and the values are entity type
+ * IDs.
+ *
+ * @var array
+ */
+ protected $baseTablesEntityType = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(SelectInterface $sql_query) {
+ parent::__construct($sql_query);
+
+ $this->workspaceManager = \Drupal::service('workspace.manager');
+
+ // The join between the first 'workspace_association' table and base table
+ // of the query is done \Drupal\workspace\EntityQuery\QueryTrait::prepare(),
+ // so we need to initialize its entry manually.
+ if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+ $this->contentWorkspaceTables['base_table'] = 'workspace_association';
+ $this->baseTablesEntityType['base_table'] = $this->sqlQuery->getMetaData('entity_type');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addField($field, $type, $langcode) {
+ // The parent method uses shared and dedicated revision tables only when the
+ // entity query is instructed to query all revisions. However, if we are
+ // looking for workspace-specific revisions, we have to force the parent
+ // method to always pick the revision tables if the field being queried is
+ // revisionable.
+ if ($active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id')) {
+ $previous_all_revisions = $this->sqlQuery->getMetaData('all_revisions');
+ $this->sqlQuery->addMetaData('all_revisions', TRUE);
+ }
+
+ $alias = parent::addField($field, $type, $langcode);
+
+ // Restore the 'all_revisions' metadata because we don't want to interfere
+ // with the rest of the query.
+ if (isset($previous_all_revisions)) {
+ $this->sqlQuery->addMetaData('all_revisions', $previous_all_revisions);
+ }
+
+ return $alias;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function addJoin($type, $table, $join_condition, $langcode, $delta = NULL) {
+ if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+ // The join condition for a shared or dedicated field table is in the form
+ // of "%alias.$id_field = $base_table.$id_field". Whenever we join a field
+ // table we have to check:
+ // 1) if $base_table is of an entity type that can belong to a workspace;
+ // 2) if $id_field is the revision key of that entity type or the special
+ // 'revision_id' string used when joining dedicated field tables.
+ // If those two conditions are met, we have to update the join condition
+ // to also look for a possible workspace-specific revision using COALESCE.
+ $condition_parts = explode(' = ', $join_condition);
+ list($base_table, $id_field) = explode('.', $condition_parts[1]);
+
+ if (isset($this->baseTablesEntityType[$base_table])) {
+ $entity_type_id = $this->baseTablesEntityType[$base_table];
+ $revision_key = $this->entityManager->getDefinition($entity_type_id)->getKey('revision');
+
+ if ($id_field === $revision_key || $id_field === 'revision_id') {
+ $workspace_association_table = $this->contentWorkspaceTables[$base_table];
+ $join_condition = "{$condition_parts[0]} = COALESCE($workspace_association_table.target_entity_revision_id, {$condition_parts[1]})";
+ }
+ }
+ }
+
+ return parent::addJoin($type, $table, $join_condition, $langcode, $delta);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
+ $next_base_table_alias = parent::addNextBaseTable($entity_type, $table, $sql_column, $field_storage);
+
+ $active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id');
+ if ($active_workspace_id && $this->workspaceManager->isEntityTypeSupported($entity_type)) {
+ $this->addWorkspaceAssociationJoin($entity_type->id(), $next_base_table_alias, $active_workspace_id);
+ }
+
+ return $next_base_table_alias;
+ }
+
+ /**
+ * Adds a new join to the 'workspace_association' table for an entity base table.
+ *
+ * This method assumes that the active workspace has already been determined
+ * to be a non-default workspace.
+ *
+ * @param string $entity_type_id
+ * The ID of the entity type whose base table we are joining.
+ * @param string $base_table_alias
+ * The alias of the entity type's base table.
+ * @param string $active_workspace_id
+ * The ID of the active workspace.
+ *
+ * @return string
+ * The alias of the joined table.
+ */
+ public function addWorkspaceAssociationJoin($entity_type_id, $base_table_alias, $active_workspace_id) {
+ if (!isset($this->contentWorkspaceTables[$base_table_alias])) {
+ $entity_type = $this->entityManager->getDefinition($entity_type_id);
+ $id_field = $entity_type->getKey('id');
+
+ // LEFT join the Workspace association entity's table so we can properly
+ // include live content along with a possible workspace-specific revision.
+ $this->contentWorkspaceTables[$base_table_alias] = $this->sqlQuery->leftJoin('workspace_association', NULL, "%alias.target_entity_type_id = '$entity_type_id' AND %alias.target_entity_id = $base_table_alias.$id_field AND %alias.workspace = '$active_workspace_id'");
+
+ $this->baseTablesEntityType[$base_table_alias] = $entity_type->id();
+ }
+ return $this->contentWorkspaceTables[$base_table_alias];
+ }
+
+}
diff --git a/core/modules/workspace/src/EntityTypeInfo.php b/core/modules/workspace/src/EntityTypeInfo.php
new file mode 100644
index 0000000..d7995df
--- /dev/null
+++ b/core/modules/workspace/src/EntityTypeInfo.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Manipulates entity type information.
+ *
+ * This class contains primarily bridged hooks for compile-time or
+ * cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
+ *
+ * @internal
+ */
+class EntityTypeInfo implements ContainerInjectionInterface {
+
+ /**
+ * The entity type manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a new EntityTypeInfo instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('workspace.manager')
+ );
+ }
+
+ /**
+ * Adds the "EntityWorkspaceConflict" constraint to eligible entity types.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
+ * An associative array of all entity type definitions, keyed by the entity
+ * type name. Passed by reference.
+ *
+ * @see hook_entity_type_build()
+ */
+ public function entityTypeBuild(array &$entity_types) {
+ foreach ($entity_types as $entity_type) {
+ if ($this->workspaceManager->isEntityTypeSupported($entity_type)) {
+ $entity_type->addConstraint('EntityWorkspaceConflict');
+ }
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
new file mode 100644
index 0000000..ea029c4
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceAccessException;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Handle activation of a workspace on administrative pages.
+ */
+class WorkspaceActivateForm extends EntityConfirmFormBase {
+
+ /**
+ * The workspace entity.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $entity;
+
+ /**
+ * The workspace replication manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new WorkspaceActivateForm.
+ *
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ */
+ public function __construct(WorkspaceManagerInterface $workspace_manager, MessengerInterface $messenger) {
+ $this->workspaceManager = $workspace_manager;
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('workspace.manager'),
+ $container->get('messenger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQuestion() {
+ return $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $this->entity->label()]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return $this->t('Activate the %workspace workspace.', ['%workspace' => $this->entity->label()]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl() {
+ return $this->entity->toUrl('collection');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildForm($form, $form_state);
+
+ // Content entity forms do not use the parent's #after_build callback.
+ unset($form['#after_build']);
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function actions(array $form, FormStateInterface $form_state) {
+ $actions = parent::actions($form, $form_state);
+ $actions['cancel']['#attributes']['class'][] = 'dialog-cancel';
+ return $actions;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ try {
+ $this->workspaceManager->setActiveWorkspace($this->entity);
+ $this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $this->entity->label()]));
+ $form_state->setRedirectUrl($this->entity->toUrl('collection'));
+ }
+ catch (WorkspaceAccessException $e) {
+ $this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $this->entity->label()]));
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceDeleteForm.php b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php
new file mode 100644
index 0000000..4a14bc6
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\ContentEntityDeleteForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a form for deleting a workspace.
+ *
+ * @internal
+ */
+class WorkspaceDeleteForm extends ContentEntityDeleteForm {
+
+ /**
+ * The workspace entity.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $entity;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildForm($form, $form_state);
+ $source_rev_diff = $this->entity->getRepositoryHandler()->getDifferringRevisionIdsOnSource();
+ $items = [];
+ foreach ($source_rev_diff as $entity_type_id => $revision_ids) {
+ $label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel();
+ $items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]);
+ }
+ $form['revisions'] = [
+ '#theme' => 'item_list',
+ '#title' => $this->t('The following will also be deleted:'),
+ '#items' => $items,
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return $this->t('This action cannot be undone, and will also delete all content created in this workspace.');
+ }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceDeployForm.php b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
new file mode 100644
index 0000000..2abd395
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
@@ -0,0 +1,193 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides the workspace deploy form.
+ */
+class WorkspaceDeployForm extends ContentEntityForm {
+
+ /**
+ * The workspace entity.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $entity;
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new WorkspaceDeployForm.
+ *
+ * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+ * The entity repository service.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+ * The entity type bundle service.
+ * @param \Drupal\Component\Datetime\TimeInterface $time
+ * The time service.
+ */
+ public function __construct(EntityRepositoryInterface $entity_repository, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+ parent::__construct($entity_repository, $entity_type_bundle_info, $time);
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity.repository'),
+ $container->get('messenger'),
+ $container->get('entity_type.bundle.info'),
+ $container->get('datetime.time')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ $repository_handler = $this->entity->getRepositoryHandler();
+
+ $args = [
+ '%source_label' => $this->entity->label(),
+ '%target_label' => $repository_handler->getLabel(),
+ ];
+ $form['#title'] = $this->t('Deploy %source_label workspace', $args);
+
+ // List the changes that can be pushed.
+ if ($source_rev_diff = $repository_handler->getDifferringRevisionIdsOnSource()) {
+ $total_count = $repository_handler->getNumberOfChangesOnSource();
+ $form['deploy'] = [
+ '#theme' => 'item_list',
+ '#title' => $this->formatPlural($total_count, 'There is @count item that can be deployed from %source_label to %target_label', 'There are @count items that can be deployed from %source_label to %target_label', $args),
+ '#items' => [],
+ '#total_count' => $total_count,
+ ];
+ foreach ($source_rev_diff as $entity_type_id => $revision_difference) {
+ $form['deploy']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
+ }
+ }
+
+ // List the changes that can be pulled.
+ if ($target_rev_diff = $repository_handler->getDifferringRevisionIdsOnTarget()) {
+ $total_count = $repository_handler->getNumberOfChangesOnTarget();
+ $form['refresh'] = [
+ '#theme' => 'item_list',
+ '#title' => $this->formatPlural($total_count, 'There is @count item that can be refreshed from %target_label to %source_label', 'There are @count items that can be refreshed from %target_label to %source_label', $args),
+ '#items' => [],
+ '#total_count' => $total_count,
+ ];
+ foreach ($target_rev_diff as $entity_type_id => $revision_difference) {
+ $form['deploy']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
+ }
+ }
+
+ // If there are no changes to push or pull, show an informational message.
+ if (!isset($form['deploy']) && !isset($form['refresh'])) {
+ $form['help'] = [
+ '#markup' => $this->t('There are no changes that can be deployed from %source_label to %target_label.', $args),
+ ];
+ }
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function actions(array $form, FormStateInterface $form_state) {
+ $elements = parent::actions($form, $form_state);
+ unset($elements['delete']);
+
+ $repository_handler = $this->entity->getRepositoryHandler();
+
+ if (isset($form['deploy'])) {
+ $total_count = $form['deploy']['#total_count'];
+ $elements['submit']['#value'] = $this->formatPlural($total_count, 'Deploy @count item to @target', 'Deploy @count items to @target', ['@target' => $repository_handler->getLabel()]);
+ $elements['submit']['#submit'] = ['::submitForm', '::deploy'];
+ }
+ else {
+ // Do not allow the 'Deploy' operation if there's nothing to push.
+ $elements['submit']['#value'] = $this->t('Deploy');
+ $elements['submit']['#disabled'] = TRUE;
+ }
+
+ // Only show the 'Refresh' operation if there's something to pull.
+ if (isset($form['refresh'])) {
+ $total_count = $form['refresh']['#total_count'];
+ $elements['refresh'] = [
+ '#type' => 'submit',
+ '#value' => $this->formatPlural($total_count, 'Refresh @count item from @target', 'Refresh @count items from @target', ['@target' => $repository_handler->getLabel()]),
+ '#submit' => ['::submitForm', '::refresh'],
+ ];
+ }
+
+ $elements['cancel'] = [
+ '#type' => 'link',
+ '#title' => $this->t('Cancel'),
+ '#attributes' => ['class' => ['button']],
+ '#url' => $this->entity->toUrl('collection'),
+ ];
+
+ return $elements;
+ }
+
+ /**
+ * Form submission handler; deploys the content to the workspace's target.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public function deploy(array &$form, FormStateInterface $form_state) {
+ $workspace = $this->entity;
+
+ try {
+ $workspace->push();
+ $this->messenger->addMessage($this->t('Successful deployment.'));
+ }
+ catch (\Exception $e) {
+ $this->messenger->addMessage($this->t('Deployment failed. All errors have been logged.'), 'error');
+ }
+ }
+
+ /**
+ * Form submission handler; pulls the target's content into a workspace.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public function refresh(array &$form, FormStateInterface $form_state) {
+ $workspace = $this->entity;
+
+ try {
+ $workspace->pull();
+ $this->messenger->addMessage($this->t('Refresh successful.'));
+ }
+ catch (\Exception $e) {
+ $this->messenger->addMessage($this->t('Refresh failed. All errors have been logged.'), 'error');
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceForm.php b/core/modules/workspace/src/Form/WorkspaceForm.php
new file mode 100644
index 0000000..c0085b4
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceForm.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form controller for the workspace edit forms.
+ */
+class WorkspaceForm extends ContentEntityForm {
+
+ /**
+ * The workspace entity.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $entity;
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new WorkspaceForm.
+ *
+ * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+ * The entity repository service.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+ * The entity type bundle service.
+ * @param \Drupal\Component\Datetime\TimeInterface $time
+ * The time service.
+ */
+ public function __construct(EntityRepositoryInterface $entity_repository, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+ parent::__construct($entity_repository, $entity_type_bundle_info, $time);
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity.repository'),
+ $container->get('messenger'),
+ $container->get('entity_type.bundle.info'),
+ $container->get('datetime.time')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $workspace = $this->entity;
+
+ if ($this->operation == 'edit') {
+ $form['#title'] = $this->t('Edit workspace %label', ['%label' => $workspace->label()]);
+ }
+ $form['label'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Label'),
+ '#maxlength' => 255,
+ '#default_value' => $workspace->label(),
+ '#required' => TRUE,
+ ];
+
+ $form['id'] = [
+ '#type' => 'machine_name',
+ '#title' => $this->t('Workspace ID'),
+ '#maxlength' => 255,
+ '#default_value' => $workspace->id(),
+ '#disabled' => !$workspace->isNew(),
+ '#machine_name' => [
+ 'exists' => '\Drupal\workspace\Entity\Workspace::load',
+ ],
+ '#element_validate' => [],
+ ];
+
+ return parent::form($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditedFieldNames(FormStateInterface $form_state) {
+ return array_merge([
+ 'label',
+ 'id',
+ ], parent::getEditedFieldNames($form_state));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
+ // Manually flag violations of fields not handled by the form display. This
+ // is necessary as entity form displays only flag violations for fields
+ // contained in the display.
+ $field_names = [
+ 'label',
+ 'id',
+ ];
+ foreach ($violations->getByFields($field_names) as $violation) {
+ list($field_name) = explode('.', $violation->getPropertyPath(), 2);
+ $form_state->setErrorByName($field_name, $violation->getMessage());
+ }
+ parent::flagViolations($violations, $form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $workspace = $this->entity;
+ $workspace->setNewRevision(TRUE);
+ $status = $workspace->save();
+
+ $info = ['%info' => $workspace->label()];
+ $context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()];
+ $logger = $this->logger('workspace');
+
+ if ($status == SAVED_UPDATED) {
+ $logger->notice('@type: updated %info.', $context);
+ $this->messenger->addMessage($this->t('Workspace %info has been updated.', $info));
+ }
+ else {
+ $logger->notice('@type: added %info.', $context);
+ $this->messenger->addMessage($this->t('Workspace %info has been created.', $info));
+ }
+
+ if ($workspace->id()) {
+ $form_state->setValue('id', $workspace->id());
+ $form_state->set('id', $workspace->id());
+
+ $collection_url = $workspace->toUrl('collection');
+ $redirect = $collection_url->access() ? $collection_url : Url::fromRoute('<front>');
+ $form_state->setRedirectUrl($redirect);
+ }
+ else {
+ $this->messenger->addError($this->t('The workspace could not be saved.'));
+ $form_state->setRebuild();
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
new file mode 100644
index 0000000..6ac54a2
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceAccessException;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form that activates a different workspace.
+ */
+class WorkspaceSwitcherForm extends FormBase {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * The workspace entity storage handler.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $workspaceStorage;
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * Constructs a new WorkspaceSwitcherForm.
+ *
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ */
+ public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
+ $this->workspaceManager = $workspace_manager;
+ $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('workspace.manager'),
+ $container->get('entity_type.manager'),
+ $container->get('messenger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'workspace_switcher_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $workspaces = $this->workspaceStorage->loadMultiple();
+ $workspace_labels = [];
+ foreach ($workspaces as $workspace) {
+ $workspace_labels[$workspace->id()] = $workspace->label();
+ }
+
+ $active_workspace = $this->workspaceManager->getActiveWorkspace();
+ unset($workspace_labels[$active_workspace->id()]);
+
+ $form['current'] = [
+ '#type' => 'item',
+ '#title' => $this->t('Current workspace'),
+ '#markup' => $active_workspace->label(),
+ '#wrapper_attributes' => [
+ 'class' => ['container-inline'],
+ ],
+ ];
+
+ $form['workspace_id'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Select workspace'),
+ '#required' => TRUE,
+ '#options' => $workspace_labels,
+ '#wrapper_attributes' => [
+ 'class' => ['container-inline'],
+ ],
+ ];
+
+ $form['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Activate'),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $id = $form_state->getValue('workspace_id');
+
+ /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+ $workspace = $this->workspaceStorage->load($id);
+
+ try {
+ $this->workspaceManager->setActiveWorkspace($workspace);
+ $this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $workspace->label()]));
+ $form_state->setRedirect('<front>');
+ }
+ catch (WorkspaceAccessException $e) {
+ $this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $workspace->label()]));
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
new file mode 100644
index 0000000..d4310db
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines the default workspace negotiator.
+ */
+class DefaultWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+ /**
+ * The workspace storage handler.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $workspaceStorage;
+
+ /**
+ * The default workspace entity.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $defaultWorkspace;
+
+ /**
+ * Constructor.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+ $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function applies(Request $request) {
+ return TRUE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getActiveWorkspace(Request $request) {
+ if (!$this->defaultWorkspace) {
+ $default_workspace = $this->workspaceStorage->create([
+ 'id' => WorkspaceInterface::DEFAULT_WORKSPACE,
+ 'label' => Unicode::ucwords(WorkspaceInterface::DEFAULT_WORKSPACE),
+ 'target' => '',
+ ]);
+ $default_workspace->enforceIsNew(FALSE);
+
+ $this->defaultWorkspace = $default_workspace;
+ }
+
+ return $this->defaultWorkspace;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setActiveWorkspace(WorkspaceInterface $workspace) {}
+
+}
diff --git a/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
new file mode 100644
index 0000000..1d45dfd
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Session\Session;
+
+/**
+ * Defines the session workspace negotiator.
+ */
+class SessionWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+ /**
+ * The current user.
+ *
+ * @var \Drupal\Core\Session\AccountInterface
+ */
+ protected $currentUser;
+
+ /**
+ * The session.
+ *
+ * @var \Symfony\Component\HttpFoundation\Session\Session
+ */
+ protected $session;
+
+ /**
+ * The workspace storage handler.
+ *
+ * @var \Drupal\Core\Entity\EntityStorageInterface
+ */
+ protected $workspaceStorage;
+
+ /**
+ * Constructor.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $current_user
+ * The current user.
+ * @param \Symfony\Component\HttpFoundation\Session\Session $session
+ * The session.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ */
+ public function __construct(AccountInterface $current_user, Session $session, EntityTypeManagerInterface $entity_type_manager) {
+ $this->currentUser = $current_user;
+ $this->session = $session;
+ $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function applies(Request $request) {
+ // This negotiator only applies if the current user is authenticated.
+ return $this->currentUser->isAuthenticated();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getActiveWorkspace(Request $request) {
+ $workspace_id = $this->session->get('active_workspace_id');
+
+ if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) {
+ return $workspace;
+ }
+
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setActiveWorkspace(WorkspaceInterface $workspace) {
+ $this->session->set('active_workspace_id', $workspace->id());
+ }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
new file mode 100644
index 0000000..5ec824e
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Workspace negotiators provide a way to get the active workspace.
+ *
+ * \Drupal\workspace\WorkspaceManager acts as the service collector for
+ * Workspace negotiators.
+ */
+interface WorkspaceNegotiatorInterface {
+
+ /**
+ * Checks whether the negotiator applies to the current request or not.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The HTTP request.
+ *
+ * @return bool
+ * TRUE if the negotiator applies for the current request, FALSE otherwise.
+ */
+ public function applies(Request $request);
+
+ /**
+ * Gets the negotiated workspace, if any.
+ *
+ * Note that it is the responsibility of each implementation to check whether
+ * the negotiated workspace actually exists in the storage.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The HTTP request.
+ *
+ * @return \Drupal\workspace\WorkspaceInterface|null
+ * The negotiated workspace or NULL if the negotiator could not determine a
+ * valid workspace.
+ */
+ public function getActiveWorkspace(Request $request);
+
+ /**
+ * Sets the negotiated workspace.
+ *
+ * @param \Drupal\workspace\WorkspaceInterface $workspace
+ * The workspace entity.
+ */
+ public function setActiveWorkspace(WorkspaceInterface $workspace);
+
+}
diff --git a/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php b/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php
new file mode 100644
index 0000000..e939715
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Block;
+
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workspace\Form\WorkspaceSwitcherForm;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a 'Workspace switcher' block.
+ *
+ * @Block(
+ * id = "workspace_switcher",
+ * admin_label = @Translation("Workspace switcher"),
+ * category = @Translation("Workspace"),
+ * )
+ */
+class WorkspaceSwitcherBlock extends BlockBase implements ContainerFactoryPluginInterface {
+
+ /**
+ * The form builder.
+ *
+ * @var \Drupal\Core\Form\FormBuilderInterface
+ */
+ protected $formBuilder;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * Constructs a new WorkspaceSwitcherBlock instance.
+ *
+ * @param array $configuration
+ * The plugin configuration.
+ * @param string $plugin_id
+ * The plugin ID.
+ * @param mixed $plugin_definition
+ * The plugin definition.
+ * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+ * The form builder.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->formBuilder = $form_builder;
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('form_builder'),
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build() {
+ $build = [
+ 'form' => $this->formBuilder->getForm(WorkspaceSwitcherForm::class),
+ '#cache' => [
+ 'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(),
+ 'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(),
+ ],
+ ];
+ return $build;
+ }
+
+}
diff --git a/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php b/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php
new file mode 100644
index 0000000..fef5942
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php
@@ -0,0 +1,217 @@
+<?php
+
+namespace Drupal\workspace\Plugin\RepositoryHandler;
+
+use Drupal\Core\Database\Connection;
+use Drupal\workspace\RepositoryHandlerBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceConflictException;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a plugin which replicates content to the default (Live) workspace.
+ *
+ * @RepositoryHandler(
+ * id = "live",
+ * label = @Translation("Live"),
+ * description = @Translation("The default (Live) workspace."),
+ * )
+ */
+class LiveRepositoryHandler extends RepositoryHandlerBase implements RepositoryHandlerInterface, ContainerFactoryPluginInterface {
+
+ /**
+ * The source workspace entity for the repository handler.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $sourceWorkspace;
+
+ /**
+ * The target workspace entity for the repository handler.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $targetWorkspace;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The database connection.
+ *
+ * @var \Drupal\Core\Database\Connection
+ */
+ protected $database;
+
+ /**
+ * The workspace association storage.
+ *
+ * @var \Drupal\workspace\WorkspaceAssociationStorageInterface
+ */
+ protected $workspaceAssociationStorage;
+
+ /**
+ * Constructs a new LiveRepositoryHandler.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Database\Connection $database
+ * Database connection.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, Connection $database) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+ $this->entityTypeManager = $entity_type_manager;
+ $this->database = $database;
+ $this->workspaceAssociationStorage = $entity_type_manager->getStorage('workspace_association');
+ $this->sourceWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->source);
+ $this->targetWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->target);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('entity_type.manager'),
+ $container->get('database')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ $this->dependencies = parent::calculateDependencies();
+ $this->addDependency($this->sourceWorkspace->getConfigDependencyKey(), $this->sourceWorkspace->getConfigDependencyName());
+
+ return $this->dependencies;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function push() {
+ if ($this->checkConflictsOnTarget()) {
+ throw new WorkspaceConflictException();
+ }
+
+ $transaction = $this->database->startTransaction();
+ try {
+ // @todo Handle the publishing of a workspace with a batch operation in
+ // https://www.drupal.org/node/2958752.
+ foreach ($this->getDifferringRevisionIdsOnSource() as $entity_type_id => $revision_difference) {
+ $entity_revisions = $this->entityTypeManager->getStorage($entity_type_id)
+ ->loadMultipleRevisions(array_keys($revision_difference));
+ /** @var \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity */
+ foreach ($entity_revisions as $entity) {
+ // When pushing workspace-specific revisions to the default workspace
+ // (Live), we simply need to mark them as default revisions.
+ // @todo Remove this dynamic property once we have an API for
+ // associating temporary data with an entity:
+ // https://www.drupal.org/node/2896474.
+ $entity->_isReplicating = TRUE;
+ $entity->isDefaultRevision(TRUE);
+ $entity->save();
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $transaction->rollBack();
+ watchdog_exception('workspace', $e);
+ throw $e;
+ }
+
+ // Notify the workspace association storage that a workspace has been
+ // pushed.
+ $this->workspaceAssociationStorage->postPush($this->sourceWorkspace);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function pull() {
+ // Nothing to do for now, pulling in changes can only be implemented when we
+ // are able to resolve conflicts.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function checkConflictsOnTarget() {
+ // Nothing to do for now, we can not get to a conflicting state because an
+ // entity which is being edited in a workspace can not be edited in any
+ // other workspace.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDifferringRevisionIdsOnTarget() {
+ $target_revision_difference = [];
+
+ $tracked_entities = $this->workspaceAssociationStorage->getTrackedEntities($this->source);
+ foreach ($tracked_entities as $entity_type_id => $tracked_revisions) {
+ $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+ // Get the latest revision IDs for all the entities that are tracked by
+ // the source workspace.
+ $query = $this->entityTypeManager
+ ->getStorage($entity_type_id)
+ ->getQuery()
+ ->condition($entity_type->getKey('id'), $tracked_revisions, 'IN')
+ ->latestRevision();
+ $result = $query->execute();
+
+ // Now we compare the revision IDs which are tracked by the source
+ // workspace to the latest revision IDs of those entities and the
+ // difference between these two arrays gives us all the entities which
+ // have been modified on the target.
+ if ($revision_difference = array_diff_key($result, $tracked_revisions)) {
+ $target_revision_difference[$entity_type_id] = $revision_difference;
+ }
+ }
+
+ return $target_revision_difference;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDifferringRevisionIdsOnSource() {
+ // Get the Workspace association revisions which haven't been pushed yet.
+ return $this->workspaceAssociationStorage->getTrackedEntities($this->source);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNumberOfChangesOnTarget() {
+ $total_changes = $this->getDifferringRevisionIdsOnTarget();
+ return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNumberOfChangesOnSource() {
+ $total_changes = $this->getDifferringRevisionIdsOnSource();
+ return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
+ }
+
+}
diff --git a/core/modules/workspace/src/Plugin/RepositoryHandler/NullRepositoryHandler.php b/core/modules/workspace/src/Plugin/RepositoryHandler/NullRepositoryHandler.php
new file mode 100644
index 0000000..ce2d515
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/RepositoryHandler/NullRepositoryHandler.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\workspace\Plugin\RepositoryHandler;
+
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\workspace\RepositoryHandlerInterface;
+
+/**
+ * Defines a fallback repository handler plugin.
+ *
+ * @RepositoryHandler(
+ * id = "null",
+ * label = @Translation("Missing repository handler"),
+ * description = @Translation("Provides a fallback for missing repository handlers. Do not use."),
+ * )
+ */
+class NullRepositoryHandler extends PluginBase implements RepositoryHandlerInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function push() {
+ // Nothing to do here.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function pull() {
+ // Nothing to do here.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function checkConflictsOnTarget() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDifferringRevisionIdsOnTarget() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDifferringRevisionIdsOnSource() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNumberOfChangesOnTarget() {
+ return 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNumberOfChangesOnSource() {
+ return 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLabel() {
+ return $this->getPluginDefinition()['label'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return $this->getPluginDefinition()['description'];
+ }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php
new file mode 100644
index 0000000..a33a8c7
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Deleted workspace constraint.
+ *
+ * @Constraint(
+ * id = "DeletedWorkspace",
+ * label = @Translation("Deleted workspace", context = "Validation"),
+ * )
+ */
+class DeletedWorkspaceConstraint extends Constraint {
+
+ /**
+ * The default violation message.
+ *
+ * @var string
+ */
+ public $message = 'A workspace with this ID has been deleted but data still exists for it.';
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php
new file mode 100644
index 0000000..695a3383
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\workspace\WorkspaceAssociationStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Checks if data still exists for a deleted workspace ID.
+ */
+class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+ /**
+ * The workspace association storage.
+ *
+ * @var \Drupal\workspace\WorkspaceAssociationStorageInterface
+ */
+ protected $workspaceAssociationStorage;
+
+ /**
+ * Creates a new DeletedWorkspaceConstraintValidator instance.
+ *
+ * @param \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage
+ * The workspace association storage.
+ */
+ public function __construct(WorkspaceAssociationStorageInterface $workspace_association_storage) {
+ $this->workspaceAssociationStorage = $workspace_association_storage;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager')->getStorage('workspace_association')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($value, Constraint $constraint) {
+ /** @var \Drupal\Core\Field\FieldItemListInterface $value */
+ // This constraint applies only to newly created workspace entities.
+ if (!isset($value) || !$value->getEntity()->isNew()) {
+ return;
+ }
+
+ $count = $this->workspaceAssociationStorage
+ ->getQuery()
+ ->allRevisions()
+ ->accessCheck(FALSE)
+ ->condition('workspace', $value->getEntity()->id())
+ ->count()
+ ->execute();
+ if ($count) {
+ $this->context->addViolation($constraint->message);
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php
new file mode 100644
index 0000000..886c700
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Validation constraint for an entity being edited in multiple workspaces.
+ *
+ * @Constraint(
+ * id = "EntityWorkspaceConflict",
+ * label = @Translation("Entity workspace conflict", context = "Validation"),
+ * type = {"entity"}
+ * )
+ */
+class EntityWorkspaceConflictConstraint extends Constraint {
+
+ /**
+ * The default violation message.
+ *
+ * @var string
+ */
+ public $message = 'The content is being edited in the %label workspace. As a result, your changes cannot be saved.';
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
new file mode 100644
index 0000000..2807fc6
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Validates the EntityWorkspaceConflict constraint.
+ */
+class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs an EntityUntranslatableFieldsConstraintValidator object.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('workspace.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($entity, Constraint $constraint) {
+ /** @var \Drupal\Core\Entity\EntityInterface $entity */
+ if (isset($entity) && !$entity->isNew()) {
+ /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+ $workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity);
+ $active_workspace = $this->workspaceManager->getActiveWorkspace();
+
+ if ($workspace_ids && !in_array($active_workspace->id(), $workspace_ids, TRUE)) {
+ // An entity can only be edited in one workspace.
+ $workspace_id = reset($workspace_ids);
+ $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+
+ $this->context->buildViolation($constraint->message)
+ ->setParameter('%label', $workspace->label())
+ ->addViolation();
+ }
+ }
+ }
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerBase.php b/core/modules/workspace/src/RepositoryHandlerBase.php
new file mode 100644
index 0000000..636fc28
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerBase.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\DependencyTrait;
+use Drupal\Core\Plugin\PluginBase;
+
+/**
+ * Defines a base RepositoryHandler plugin implementation.
+ *
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see \Drupal\workspace\RepositoryHandlerManager
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see plugin_api
+ */
+abstract class RepositoryHandlerBase extends PluginBase implements RepositoryHandlerInterface {
+
+ use DependencyTrait;
+
+ /**
+ * The source repository identifier.
+ *
+ * @var string
+ */
+ protected $source;
+
+ /**
+ * The target repository identifier.
+ *
+ * @var string
+ */
+ protected $target;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+ if (!isset($configuration['source'])) {
+ throw new \InvalidArgumentException('Missing repository handler source configuration');
+ }
+ if (!isset($configuration['target'])) {
+ throw new \InvalidArgumentException('Missing repository handler target configuration');
+ }
+
+ $this->source = $configuration['source'];
+ $this->target = $configuration['target'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLabel() {
+ return $this->getPluginDefinition()['label'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ return $this->getPluginDefinition()['description'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ return [];
+ }
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerInterface.php b/core/modules/workspace/src/RepositoryHandlerInterface.php
new file mode 100644
index 0000000..660ab62
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerInterface.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\DerivativeInspectionInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+
+/**
+ * RepositoryHandler plugins handle content replication.
+ *
+ * The replication will use data from the target repository handler plugin to
+ * merge the content between the source and the target. For example an internal
+ * replication might just need the workspace IDs, but a contrib module
+ * performing an external replication may need hostname, port, username,
+ * password etc.
+ */
+interface RepositoryHandlerInterface extends PluginInspectionInterface, DerivativeInspectionInterface {
+
+ /**
+ * Indicate that an item has been updated both on the source and the target.
+ *
+ * @var int
+ */
+ const CONFLICT_UPDATE_ON_CHANGE = 1;
+
+ /**
+ * Indicate that an item updated on the source has been deleted on the target.
+ *
+ * @var int
+ */
+ const CONFLICT_UPDATE_ON_DELETE = 2;
+
+ /**
+ * Indicate that an item deleted on the source has been changed on the target.
+ *
+ * @var int
+ */
+ const CONFLICT_DELETE_ON_CHANGE = 3;
+
+ /**
+ * Returns the label of the repository handler.
+ *
+ * This is used as a form label where a user selects the replication target.
+ *
+ * @return string
+ * The label text, which could be a plain string or an object that can be
+ * cast to a string.
+ */
+ public function getLabel();
+
+ /**
+ * Returns the repository handler plugin description.
+ *
+ * @return string
+ * The description text, which could be a plain string or an object that can
+ * be cast to a string.
+ */
+ public function getDescription();
+
+ /**
+ * Pushes content from a source repository to a target repository.
+ */
+ public function push();
+
+ /**
+ * Pulls content from a target repository to a source repository.
+ */
+ public function pull();
+
+ /**
+ * Checks if there are any conflicts between the source and the target.
+ *
+ * @return array
+ * Returns an array consisting of the number of conflicts between the source
+ * and the target, keyed by the conflict type constant.
+ */
+ public function checkConflictsOnTarget();
+
+ /**
+ * Gets the revision identifiers for items which have changed on the target.
+ *
+ * @return array
+ * A multidimensional array of revision identifiers, either the revision ID
+ * or the revision UUID, keyed by entity type IDs.
+ *
+ * @todo Update the return values to be only UUIDs and revision UUIDs in
+ * https://www.drupal.org/node/2958752
+ */
+ public function getDifferringRevisionIdsOnTarget();
+
+ /**
+ * Gets the revision identifiers for items which have changed on the source.
+ *
+ * @return array
+ * A multidimensional array of revision identifiers, either the revision ID
+ * or the revision UUID, keyed by entity type IDs.
+ *
+ * @todo Update the return values to be only UUIDs and revision UUIDs in
+ * https://www.drupal.org/node/2958752
+ */
+ public function getDifferringRevisionIdsOnSource();
+
+ /**
+ * Gets the total number of items which have changed on the target.
+ *
+ * This returns the aggregated changes count across all entity types.
+ * For example, if two nodes and one taxonomy term have changed on the target,
+ * the return value is 3.
+ *
+ * @return int
+ * The number of differing revisions.
+ */
+ public function getNumberOfChangesOnTarget();
+
+ /**
+ * Gets the total number of items which have changed on the source.
+ *
+ * This returns the aggregated changes count across all entity types.
+ * For example, if two nodes and one taxonomy term have changed on the source,
+ * the return value is 3.
+ *
+ * @return int
+ * The number of differing revisions.
+ */
+ public function getNumberOfChangesOnSource();
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerManager.php b/core/modules/workspace/src/RepositoryHandlerManager.php
new file mode 100644
index 0000000..9d92772
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerManager.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\FallbackPluginManagerInterface;
+use Drupal\Core\Plugin\CategorizingPluginManagerTrait;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+
+/**
+ * Provides a plugin manager for Repository Handlers.
+ *
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see plugin_api
+ */
+class RepositoryHandlerManager extends DefaultPluginManager implements RepositoryHandlerManagerInterface, FallbackPluginManagerInterface {
+
+ use CategorizingPluginManagerTrait;
+
+ /**
+ * Constructs a new RepositoryHandlerManager.
+ *
+ * @param \Traversable $namespaces
+ * An object that implements \Traversable which contains the root paths
+ * keyed by the corresponding namespace to look for plugin implementations.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * Cache backend instance to use.
+ * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+ * The module handler to invoke the alter hook with.
+ */
+ public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+ parent::__construct('Plugin/RepositoryHandler', $namespaces, $module_handler, 'Drupal\workspace\RepositoryHandlerInterface', 'Drupal\workspace\Annotation\RepositoryHandler');
+ $this->alterInfo('workspace_repository_handler_info');
+ $this->setCacheBackend($cache_backend, 'workspace_repository_handler');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function processDefinition(&$definition, $plugin_id) {
+ parent::processDefinition($definition, $plugin_id);
+ $this->processDefinitionCategory($definition);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createFromWorkspace(WorkspaceInterface $workspace) {
+ $target = $workspace->target->value;
+ $configuration = [
+ 'source' => $workspace->id(),
+ 'target' => $target,
+ ];
+ return $this->createInstance($target, $configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFallbackPluginId($plugin_id, array $configuration = []) {
+ return 'null';
+ }
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerManagerInterface.php b/core/modules/workspace/src/RepositoryHandlerManagerInterface.php
new file mode 100644
index 0000000..ae04e90
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerManagerInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\CategorizingPluginManagerInterface;
+
+/**
+ * Provides the interface for a plugin manager of repository handlers.
+ */
+interface RepositoryHandlerManagerInterface extends CategorizingPluginManagerInterface {
+
+ /**
+ * Creates a repository handler instance from a given workspace entity.
+ *
+ * @param \Drupal\workspace\WorkspaceInterface $workspace
+ * A workspace entity.
+ *
+ * @return \Drupal\workspace\RepositoryHandlerInterface
+ * A repository handler plugin.
+ */
+ public function createFromWorkspace(WorkspaceInterface $workspace);
+
+}
diff --git a/core/modules/workspace/src/ViewsQueryAlter.php b/core/modules/workspace/src/ViewsQueryAlter.php
new file mode 100644
index 0000000..51bb2af
--- /dev/null
+++ b/core/modules/workspace/src/ViewsQueryAlter.php
@@ -0,0 +1,422 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\views\Plugin\views\query\QueryPluginBase;
+use Drupal\views\Plugin\views\query\Sql;
+use Drupal\views\Plugin\ViewsHandlerManager;
+use Drupal\views\ViewExecutable;
+use Drupal\views\ViewsData;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for altering views queries.
+ *
+ * @internal
+ */
+class ViewsQueryAlter implements ContainerInjectionInterface {
+
+ /**
+ * The entity type manager service.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The entity field manager.
+ *
+ * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+ */
+ protected $entityFieldManager;
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * The views data.
+ *
+ * @var \Drupal\views\ViewsData
+ */
+ protected $viewsData;
+
+ /**
+ * A plugin manager which handles instances of views join plugins.
+ *
+ * @var \Drupal\views\Plugin\ViewsHandlerManager
+ */
+ protected $viewsJoinPluginManager;
+
+ /**
+ * Constructs a new ViewsQueryAlter instance.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
+ * The entity field manager.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ * @param \Drupal\views\ViewsData $views_data
+ * The views data.
+ * @param \Drupal\views\Plugin\ViewsHandlerManager $views_join_plugin_manager
+ * The views join plugin manager.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, WorkspaceManagerInterface $workspace_manager, ViewsData $views_data, ViewsHandlerManager $views_join_plugin_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->entityFieldManager = $entity_field_manager;
+ $this->workspaceManager = $workspace_manager;
+ $this->viewsData = $views_data;
+ $this->viewsJoinPluginManager = $views_join_plugin_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('entity_field.manager'),
+ $container->get('workspace.manager'),
+ $container->get('views.views_data'),
+ $container->get('plugin.manager.views.join')
+ );
+ }
+
+ /**
+ * Implements a hook bridge for hook_views_query_alter().
+ *
+ * @see hook_views_query_alter()
+ */
+ public function alterQuery(ViewExecutable $view, QueryPluginBase $query) {
+ // Don't alter any views queries if we're in the default workspace.
+ if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
+ return;
+ }
+
+ // Don't alter any non-sql views queries.
+ if (!$query instanceof Sql) {
+ return;
+ }
+
+ // Find out what entity types are represented in this query.
+ $entity_type_ids = [];
+ foreach ($query->relationships as $info) {
+ $table_data = $this->viewsData->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 = $this->entityTypeManager->getDefinitions();
+ foreach ($entity_type_ids as $entity_type_id) {
+ if ($this->workspaceManager->isEntityTypeSupported($entity_type_definitions[$entity_type_id])) {
+ $this->alterQueryForEntityType($query, $entity_type_definitions[$entity_type_id]);
+ }
+ }
+ }
+
+ /**
+ * Alters the entity type tables for a Views query.
+ *
+ * This should only be called after determining that this entity type is
+ * involved in the query, and that a non-default workspace is in use.
+ *
+ * @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.
+ */
+ protected function alterQueryForEntityType(Sql $query, EntityTypeInterface $entity_type) {
+ /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
+ $table_mapping = $this->entityTypeManager->getStorage($entity_type->id())->getTableMapping();
+ $field_storage_definitions = $this->entityFieldManager->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 = [];
+ $table_queue =& $query->getTableQueue();
+ foreach ($table_queue as $alias => &$table_info) {
+ // If we reach the workspace_association array item before any candidates,
+ // then we do not need to move it.
+ if ($table_info['table'] == 'workspace_association') {
+ 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 workspace_association table.
+ $workspace_association_table = $this->ensureWorkspaceAssociationTable($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($workspace_association_table.target_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 workspace_association table we have to move
+ // it in the table queue so that it comes before this field.
+ if (empty($move_workspace_tables[$workspace_association_table])) {
+ $move_workspace_tables[$workspace_association_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 workspace_association table join to
+ // the correct position.
+ foreach ($move_workspace_tables as $workspace_association_table => $alias) {
+ $this->moveEntityTable($query, $workspace_association_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 = $table_queue[$field_info['table']]['table'];
+ if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
+ $relationship = $table_queue[$field_info['table']]['alias'];
+ $alias = $this->ensureRevisionTable($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 = $this->ensureRevisionTable($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 and
+ // $query->count_field in https://www.drupal.org/node/2968165.
+ }
+
+ /**
+ * Adds the 'workspace_association' 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 'workspace_association' table.
+ */
+ protected function ensureWorkspaceAssociationTable($entity_type_id, Sql $query, $relationship) {
+ if (isset($query->tables[$relationship]['workspace_association'])) {
+ return $query->tables[$relationship]['workspace_association']['alias'];
+ }
+
+ $table_data = $this->viewsData->get($query->relationships[$relationship]['base']);
+
+ // Construct the join.
+ $definition = [
+ 'table' => 'workspace_association',
+ 'field' => 'target_entity_id',
+ 'left_table' => $relationship,
+ 'left_field' => $table_data['table']['base']['field'],
+ 'extra' => [
+ [
+ 'field' => 'target_entity_type_id',
+ 'value' => $entity_type_id,
+ ],
+ [
+ 'field' => 'workspace',
+ 'value' => $this->workspaceManager->getActiveWorkspace()->id(),
+ ],
+ ],
+ 'type' => 'LEFT',
+ ];
+
+ $join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
+ $join->adjusted = TRUE;
+
+ return $query->queueTable('workspace_association', $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.
+ */
+ protected function ensureRevisionTable(EntityTypeInterface $entity_type, Sql $query, $relationship) {
+ // Get the alias for the 'workspace_association' table we chain off of in
+ // the COALESCE.
+ $workspace_association_table = $this->ensureWorkspaceAssociationTable($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])) {
+ $table_queue =& $query->getTableQueue();
+ $alias = $query->tables[$relationship][$base_revision_table]['alias'];
+ if (isset($table_queue[$alias]['join']->field) && $table_queue[$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 'workspace_association' comes
+ // first.
+ if (empty($table_queue[$alias]['join']->workspace_adjusted)) {
+ $table_queue[$alias]['join'] = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
+ // We also have to ensure that our 'workspace_association' comes before
+ // this.
+ $this->moveEntityTable($query, $workspace_association_table, $alias);
+ }
+
+ return $alias;
+ }
+ }
+
+ // Construct a new join.
+ $join = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
+ return $query->queueTable($base_revision_table, $relationship, $join);
+ }
+
+ /**
+ * Fetches a join for a revision table using the workspace_association 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 $workspace_association_table
+ * The alias of the 'workspace_association' table joined to the main entity
+ * table.
+ *
+ * @return \Drupal\views\Plugin\views\join\JoinPluginInterface
+ * An adjusted views join object to add to the query.
+ */
+ protected function getRevisionTableJoin($relationship, $table, $field, $workspace_association_table) {
+ $definition = [
+ 'table' => $table,
+ 'field' => $field,
+ // Making this explicitly null allows the left table to be a formula.
+ 'left_table' => NULL,
+ 'left_field' => "COALESCE($workspace_association_table.target_entity_revision_id, $relationship.$field)",
+ ];
+
+ /** @var \Drupal\views\Plugin\views\join\JoinPluginInterface $join */
+ $join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
+ $join->adjusted = TRUE;
+ $join->workspace_adjusted = TRUE;
+
+ return $join;
+ }
+
+ /**
+ * Moves a 'workspace_association' table to appear before the given alias.
+ *
+ * Because Workspace chains possibly pre-existing tables onto the
+ * 'workspace_association' table, we have to ensure that the
+ * 'workspace_association' table appears in the query before the alias it's
+ * chained on or the SQL is invalid.
+ *
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ * The SQL query object.
+ * @param string $workspace_association_table
+ * The alias of the 'workspace_association' table.
+ * @param string $alias
+ * The alias of the table it needs to appear before.
+ */
+ protected function moveEntityTable(Sql $query, $workspace_association_table, $alias) {
+ $table_queue =& $query->getTableQueue();
+ $keys = array_keys($table_queue);
+ $current_index = array_search($workspace_association_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 = [$workspace_association_table => $table_queue[$workspace_association_table]];
+ unset($table_queue[$workspace_association_table]);
+
+ // Now move the item to the proper location in the array. Don't use
+ // array_splice() because that breaks indices.
+ $table_queue = array_slice($table_queue, 0, $index, TRUE) +
+ $splice +
+ array_slice($table_queue, $index, NULL, TRUE);
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessControlHandler.php b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
new file mode 100644
index 0000000..4bf9601
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Defines the access control handler for the workspace entity type.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceAccessControlHandler extends EntityAccessControlHandler {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+ /** @var \Drupal\workspace\WorkspaceInterface $entity */
+ if ($operation === 'delete' && $entity->isDefaultWorkspace()) {
+ return AccessResult::forbidden()->addCacheableDependency($entity);
+ }
+
+ if ($account->hasPermission('administer workspaces')) {
+ return AccessResult::allowed()->cachePerPermissions();
+ }
+
+ // The default workspace is always viewable, no matter what.
+ if ($operation == 'view' && $entity->isDefaultWorkspace()) {
+ return AccessResult::allowed()->addCacheableDependency($entity);
+ }
+
+ $permission_operation = $operation === 'update' ? 'edit' : $operation;
+
+ // Check if the user has permission to access all workspaces.
+ $access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' any workspace');
+
+ // Check if it's their own workspace, and they have permission to access
+ // their own workspace.
+ if ($access_result->isNeutral() && $account->isAuthenticated() && $account->id() === $entity->getOwnerId()) {
+ $access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' own workspace')
+ ->cachePerUser()
+ ->addCacheableDependency($entity);
+ }
+
+ return $access_result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+ return AccessResult::allowedIfHasPermission($account, 'create workspace');
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessException.php b/core/modules/workspace/src/WorkspaceAccessException.php
new file mode 100644
index 0000000..210ddae
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessException;
+
+/**
+ * Exception thrown when trying to switch to an inaccessible workspace.
+ */
+class WorkspaceAccessException extends AccessException {
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAssociationStorage.php b/core/modules/workspace/src/WorkspaceAssociationStorage.php
new file mode 100644
index 0000000..9cc4857
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAssociationStorage.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+
+/**
+ * Defines the storage handler class for the Workspace association entity type.
+ */
+class WorkspaceAssociationStorage extends SqlContentEntityStorage implements WorkspaceAssociationStorageInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postPush(WorkspaceInterface $workspace) {
+ $this->database
+ ->delete($this->entityType->getBaseTable())
+ ->condition('workspace', $workspace->id())
+ ->execute();
+ $this->database
+ ->delete($this->entityType->getRevisionTable())
+ ->condition('workspace', $workspace->id())
+ ->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTrackedEntities($workspace_id, $all_revisions = FALSE) {
+ $table = $all_revisions ? $this->getRevisionTable() : $this->getBaseTable();
+ $query = $this->database->select($table, 'base_table');
+ $query
+ ->fields('base_table', ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id'])
+ ->orderBy('target_entity_revision_id', 'ASC')
+ ->condition('workspace', $workspace_id);
+
+ $tracked_revisions = [];
+ foreach ($query->execute() as $record) {
+ $tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id;
+ }
+
+ return $tracked_revisions;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getEntityTrackingWorkspaceIds(EntityInterface $entity) {
+ $query = $this->database->select($this->getBaseTable(), 'base_table');
+ $query
+ ->fields('base_table', ['workspace'])
+ ->condition('target_entity_type_id', $entity->getEntityTypeId())
+ ->condition('target_entity_id', $entity->id());
+
+ return $query->execute()->fetchCol();
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php b/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php
new file mode 100644
index 0000000..24a7e32
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityStorageInterface;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines an interface for workspace association entity storage classes.
+ */
+interface WorkspaceAssociationStorageInterface extends ContentEntityStorageInterface {
+
+ /**
+ * Triggers clean-up operations after pushing.
+ *
+ * @param \Drupal\workspace\WorkspaceInterface $workspace
+ * A workspace entity.
+ */
+ public function postPush(WorkspaceInterface $workspace);
+
+ /**
+ * Retrieves the content revisions tracked by a given workspace.
+ *
+ * @param string $workspace_id
+ * The ID of the workspace.
+ * @param bool $all_revisions
+ * (optional) Whether to return all the tracked revisions for each entity or
+ * just the latest tracked revision. Defaults to FALSE.
+ *
+ * @return array
+ * Returns a multidimensional array where the first level keys are entity
+ * type IDs and the values are an array of entity IDs keyed by revision IDs.
+ */
+ public function getTrackedEntities($workspace_id, $all_revisions = FALSE);
+
+ /**
+ * Gets a list of workspace IDs in which an entity is tracked.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * An entity object.
+ *
+ * @return string[]
+ * An array of workspace IDs where the given entity is tracked, or an empty
+ * array if it is not tracked anywhere.
+ */
+ public function getEntityTrackingWorkspaceIds(EntityInterface $entity);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceCacheContext.php b/core/modules/workspace/src/WorkspaceCacheContext.php
new file mode 100644
index 0000000..14ce019
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceCacheContext.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\Context\CacheContextInterface;
+
+/**
+ * Defines the WorkspaceCacheContext service, for "per workspace" caching.
+ *
+ * Cache context ID: 'workspace'.
+ */
+class WorkspaceCacheContext implements CacheContextInterface {
+
+ /**
+ * The workspace manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a new WorkspaceCacheContext service.
+ *
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager.
+ */
+ public function __construct(WorkspaceManagerInterface $workspace_manager) {
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getLabel() {
+ return t('Workspace');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getContext() {
+ return $this->workspaceManager->getActiveWorkspace()->id();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheableMetadata($type = NULL) {
+ return new CacheableMetadata();
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceConflictException.php b/core/modules/workspace/src/WorkspaceConflictException.php
new file mode 100644
index 0000000..1c89b09
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceConflictException.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Drupal\workspace;
+
+/**
+ * An exception thrown when two workspaces are in a conflicting content state.
+ */
+class WorkspaceConflictException extends \RuntimeException {
+
+}
diff --git a/core/modules/workspace/src/WorkspaceInterface.php b/core/modules/workspace/src/WorkspaceInterface.php
new file mode 100644
index 0000000..411437c
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceInterface.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\user\EntityOwnerInterface;
+
+/**
+ * Defines an interface for the workspace entity type.
+ */
+interface WorkspaceInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
+
+ /**
+ * The ID of the default workspace.
+ */
+ const DEFAULT_WORKSPACE = 'live';
+
+ /**
+ * Pushes content from this workspace to the target repository.
+ */
+ public function push();
+
+ /**
+ * Pulls content from the target repository into this workspace.
+ */
+ public function pull();
+
+ /**
+ * Gets an instance of the repository handler configured for the workspace.
+ *
+ * @return \Drupal\workspace\RepositoryHandlerInterface
+ * A repository handler plugin object.
+ */
+ public function getRepositoryHandler();
+
+ /**
+ * Determines whether the workspace is the default one or not.
+ *
+ * @return bool
+ * TRUE if this workspace is the default one (e.g 'Live'), FALSE otherwise.
+ */
+ public function isDefaultWorkspace();
+
+ /**
+ * Gets the workspace creation timestamp.
+ *
+ * @return int
+ * Creation timestamp of the workspace.
+ */
+ public function getCreatedTime();
+
+ /**
+ * Sets the workspace creation timestamp.
+ *
+ * @param int $timestamp
+ * The workspace creation timestamp.
+ *
+ * @return $this
+ */
+ public function setCreatedTime($timestamp);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceListBuilder.php b/core/modules/workspace/src/WorkspaceListBuilder.php
new file mode 100644
index 0000000..e819454
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceListBuilder.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityListBuilder;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\workspace\Plugin\RepositoryHandler\NullRepositoryHandler;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class to build a listing of workspace entities.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceListBuilder extends EntityListBuilder {
+
+ /**
+ * The workspace manager service.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * Constructs a new EntityListBuilder object.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type definition.
+ * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+ * The entity storage class.
+ * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+ * The workspace manager service.
+ */
+ public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager) {
+ parent::__construct($entity_type, $storage);
+ $this->workspaceManager = $workspace_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+ return new static(
+ $entity_type,
+ $container->get('entity.manager')->getStorage($entity_type->id()),
+ $container->get('workspace.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildHeader() {
+ $header['label'] = $this->t('Workspace');
+ $header['uid'] = $this->t('Owner');
+ $header['status'] = $this->t('Status');
+
+ return $header + parent::buildHeader();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildRow(EntityInterface $entity) {
+ /** @var \Drupal\workspace\WorkspaceInterface $entity */
+ $row['label'] = $this->t('@label (@id)', ['@label' => $entity->label(), '@id' => $entity->id()]);
+ $row['owner'] = $entity->getOwner()->getDisplayname();
+ $active_workspace = $this->workspaceManager->getActiveWorkspace()->id();
+ $row['status'] = $active_workspace == $entity->id() ? $this->t('Active') : $this->t('Inactive');
+
+ return $row + parent::buildRow($entity);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDefaultOperations(EntityInterface $entity) {
+ /** @var \Drupal\workspace\WorkspaceInterface $entity */
+ $operations = parent::getDefaultOperations($entity);
+ if (isset($operations['edit'])) {
+ $operations['edit']['query']['destination'] = $entity->toUrl('collection')->toString();
+ }
+
+ $active_workspace = $this->workspaceManager->getActiveWorkspace();
+ if ($entity->id() != $active_workspace->id()) {
+ $operations['activate'] = [
+ 'title' => $this->t('Set Active'),
+ // Use a weight lower than the one of the 'Edit' operation because we
+ // want the 'Activate' operation to be the primary operation.
+ 'weight' => 0,
+ 'url' => $entity->toUrl('activate-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+ ];
+ }
+
+ if (!$entity->getRepositoryHandler() instanceof NullRepositoryHandler) {
+ $operations['deploy'] = [
+ 'title' => $this->t('Deploy content'),
+ // The 'Deploy' operation should be the default one for the currently
+ // active workspace.
+ 'weight' => ($entity->id() == $active_workspace->id()) ? 0 : 20,
+ 'url' => $entity->toUrl('deploy-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+ ];
+ }
+
+ return $operations;
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManager.php b/core/modules/workspace/src/WorkspaceManager.php
new file mode 100644
index 0000000..ee41348
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManager.php
@@ -0,0 +1,283 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\Site\Settings;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Provides the workspace manager.
+ */
+class WorkspaceManager implements WorkspaceManagerInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * An array of entity type IDs that can not belong to a workspace.
+ *
+ * By default, only entity types which are revisionable and publishable can
+ * belong to a workspace.
+ *
+ * @var string[]
+ */
+ protected $blacklist = [
+ 'workspace_association',
+ 'workspace',
+ ];
+
+ /**
+ * The request stack.
+ *
+ * @var \Symfony\Component\HttpFoundation\RequestStack
+ */
+ protected $requestStack;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The current user.
+ *
+ * @var \Drupal\Core\Session\AccountProxyInterface
+ */
+ protected $currentUser;
+
+ /**
+ * The state service.
+ *
+ * @var \Drupal\Core\State\StateInterface
+ */
+ protected $state;
+
+ /**
+ * A logger instance.
+ *
+ * @var \Psr\Log\LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * The class resolver.
+ *
+ * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+ */
+ protected $classResolver;
+
+ /**
+ * The workspace negotiator service IDs.
+ *
+ * @var array
+ */
+ protected $negotiatorIds;
+
+ /**
+ * The current active workspace.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface
+ */
+ protected $activeWorkspace;
+
+ /**
+ * Constructs a new WorkspaceManager.
+ *
+ * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+ * The request stack.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+ * The current user.
+ * @param \Drupal\Core\State\StateInterface $state
+ * The state service.
+ * @param \Psr\Log\LoggerInterface $logger
+ * A logger instance.
+ * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
+ * The class resolver.
+ * @param array $negotiator_ids
+ * The workspace negotiator service IDs.
+ */
+ public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) {
+ $this->requestStack = $request_stack;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->currentUser = $current_user;
+ $this->state = $state;
+ $this->logger = $logger;
+ $this->classResolver = $class_resolver;
+ $this->negotiatorIds = $negotiator_ids;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isEntityTypeSupported(EntityTypeInterface $entity_type) {
+ if (!isset($this->blacklist[$entity_type->id()])
+ && $entity_type->entityClassImplements(EntityPublishedInterface::class)
+ && $entity_type->isRevisionable()) {
+ return TRUE;
+ }
+ $this->blacklist[$entity_type->id()] = $entity_type->id();
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getSupportedEntityTypes() {
+ $entity_types = [];
+ foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
+ if ($this->isEntityTypeSupported($entity_type)) {
+ $entity_types[$entity_type_id] = $entity_type;
+ }
+ }
+ return $entity_types;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getActiveWorkspace() {
+ if (!isset($this->activeWorkspace)) {
+ $request = $this->requestStack->getCurrentRequest();
+ foreach ($this->negotiatorIds as $negotiator_id) {
+ $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+ if ($negotiator->applies($request)) {
+ if ($this->activeWorkspace = $negotiator->getActiveWorkspace($request)) {
+ break;
+ }
+ }
+ }
+ }
+
+ // The default workspace negotiator always returns a valid workspace.
+ return $this->activeWorkspace;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setActiveWorkspace(WorkspaceInterface $workspace) {
+ // If the current user doesn't have access to view the workspace, they
+ // shouldn't be allowed to switch to it.
+ if (!$workspace->access('view') && !$workspace->isDefaultWorkspace()) {
+ $this->logger->error('Denied access to view workspace %workspace_label for user %uid', [
+ '%workspace_label' => $workspace->label(),
+ '%uid' => $this->currentUser->id(),
+ ]);
+ throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
+ }
+
+ $this->activeWorkspace = $workspace;
+
+ // Set the workspace on the proper negotiator.
+ $request = $this->requestStack->getCurrentRequest();
+ foreach ($this->negotiatorIds as $negotiator_id) {
+ $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+ if ($negotiator->applies($request)) {
+ $negotiator->setActiveWorkspace($workspace);
+ break;
+ }
+ }
+
+ $supported_entity_types = $this->getSupportedEntityTypes();
+ foreach ($supported_entity_types as $supported_entity_type) {
+ $this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache();
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function shouldAlterOperations(EntityTypeInterface $entity_type) {
+ return $this->isEntityTypeSupported($entity_type) && !$this->getActiveWorkspace()->isDefaultWorkspace();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function purgeDeletedWorkspacesBatch() {
+ $deleted_workspace_ids = $this->state->get('workspace.deleted', []);
+
+ // Bail out early if there are no workspaces to purge.
+ if (empty($deleted_workspace_ids)) {
+ return;
+ }
+
+ $batch_size = Settings::get('entity_update_batch_size', 50);
+
+ /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+ $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+
+ // Get the first deleted workspace from the list and delete the revisions
+ // associated with it, along with the workspace_association entries.
+ $workspace_id = reset($deleted_workspace_ids);
+ $workspace_association_ids = $this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size);
+
+ if ($workspace_association_ids) {
+ $workspace_associations = $workspace_association_storage->loadMultipleRevisions(array_keys($workspace_association_ids));
+ foreach ($workspace_associations as $workspace_association) {
+ $associated_entity_storage = $this->entityTypeManager->getStorage($workspace_association->target_entity_type_id->value);
+ // Delete the associated entity revision.
+ if ($entity = $associated_entity_storage->loadRevision($workspace_association->target_entity_revision_id->value)) {
+ if ($entity->isDefaultRevision()) {
+ $entity->delete();
+ }
+ else {
+ $associated_entity_storage->deleteRevision($workspace_association->target_entity_revision_id->value);
+ }
+ }
+
+ // Delete the workspace_association revision.
+ if ($workspace_association->isDefaultRevision()) {
+ $workspace_association->delete();
+ }
+ else {
+ $workspace_association_storage->deleteRevision($workspace_association->getRevisionId());
+ }
+ }
+ }
+
+ // The purging operation above might have taken a long time, so we need to
+ // request a fresh list of workspace association IDs. If it is empty, we can
+ // go ahead and remove the deleted workspace ID entry from state.
+ if (!$this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size)) {
+ unset($deleted_workspace_ids[$workspace_id]);
+ $this->state->set('workspace.deleted', $deleted_workspace_ids);
+ }
+ }
+
+ /**
+ * Gets a list of workspace association IDs to purge.
+ *
+ * @param string $workspace_id
+ * The ID of the workspace.
+ * @param int $batch_size
+ * The maximum number of records that will be purged.
+ *
+ * @return array
+ * An array of workspace association IDs, keyed by their revision IDs.
+ */
+ protected function getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size) {
+ return $this->entityTypeManager->getStorage('workspace_association')
+ ->getQuery()
+ ->allRevisions()
+ ->accessCheck(FALSE)
+ ->condition('workspace', $workspace_id)
+ ->sort('revision_id', 'ASC')
+ ->range(0, $batch_size)
+ ->execute();
+ }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManagerInterface.php b/core/modules/workspace/src/WorkspaceManagerInterface.php
new file mode 100644
index 0000000..552f316
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManagerInterface.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+
+/**
+ * Provides an interface for managing Workspaces.
+ */
+interface WorkspaceManagerInterface {
+
+ /**
+ * Returns whether an entity type can belong to a workspace or not.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type to check.
+ *
+ * @return bool
+ * TRUE if the entity type can belong to a workspace, FALSE otherwise.
+ */
+ public function isEntityTypeSupported(EntityTypeInterface $entity_type);
+
+ /**
+ * Returns an array of entity types that can belong to workspaces.
+ *
+ * @return \Drupal\Core\Entity\EntityTypeInterface[]
+ * The entity types what can belong to workspaces.
+ */
+ public function getSupportedEntityTypes();
+
+ /**
+ * Gets the active workspace.
+ *
+ * @return \Drupal\workspace\WorkspaceInterface
+ * The active workspace entity object.
+ */
+ public function getActiveWorkspace();
+
+ /**
+ * Sets the active workspace via the workspace negotiators.
+ *
+ * @param \Drupal\workspace\WorkspaceInterface $workspace
+ * The workspace to set as active.
+ *
+ * @return $this
+ *
+ * @throws \Drupal\workspace\WorkspaceAccessException
+ * Thrown when the current user doesn't have access to view the workspace.
+ */
+ public function setActiveWorkspace(WorkspaceInterface $workspace);
+
+ /**
+ * Determines whether runtime entity operations should be altered.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ * The entity type to check.
+ *
+ * @return bool
+ * TRUE if the entity operations or queries should be altered in the current
+ * request, FALSE otherwise.
+ */
+ public function shouldAlterOperations(EntityTypeInterface $entity_type);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceServiceProvider.php b/core/modules/workspace/src/WorkspaceServiceProvider.php
new file mode 100644
index 0000000..5836493
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceServiceProvider.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+
+/**
+ * Defines a service provider for the workspace module.
+ */
+class WorkspaceServiceProvider extends ServiceProviderBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alter(ContainerBuilder $container) {
+ // Add the 'workspace' cache context as required.
+ $renderer_config = $container->getParameter('renderer.config');
+ $renderer_config['required_cache_contexts'][] = 'workspace';
+ $container->setParameter('renderer.config', $renderer_config);
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
new file mode 100644
index 0000000..04e2510
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * Test workspace entities for unauthenticated JSON requests.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonAnonTest extends WorkspaceResourceTestBase {
+
+ use AnonResourceTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'json';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'application/json';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
new file mode 100644
index 0000000..dbae2d5
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests via basic auth.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonBasicAuthTest extends WorkspaceResourceTestBase {
+
+ use BasicAuthResourceTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['basic_auth'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'json';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'application/json';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
new file mode 100644
index 0000000..f77dbf2
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests with cookie authentication.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonCookieTest extends WorkspaceResourceTestBase {
+
+ use CookieResourceTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'json';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'application/json';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
new file mode 100644
index 0000000..f2d394c
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Base class for workspace EntityResource tests.
+ */
+abstract class WorkspaceResourceTestBase extends EntityResourceTestBase {
+
+ use BcTimestampNormalizerUnixTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['workspace'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $entityTypeId = 'workspace';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $patchProtectedFieldNames = ['changed'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $firstCreatedEntityId = 'running_on_faith';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $secondCreatedEntityId = 'running_on_faith_2';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUpAuthorization($method) {
+ switch ($method) {
+ case 'GET':
+ $this->grantPermissionsToTestedRole(['view any workspace']);
+ break;
+ case 'POST':
+ $this->grantPermissionsToTestedRole(['create workspace']);
+ break;
+ case 'PATCH':
+ $this->grantPermissionsToTestedRole(['edit any workspace']);
+ break;
+ case 'DELETE':
+ $this->grantPermissionsToTestedRole(['delete any workspace']);
+ break;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function createEntity() {
+ $workspace = Workspace::create([
+ 'id' => 'layla',
+ 'label' => 'Layla',
+ 'target' => 'live',
+ ]);
+ $workspace->save();
+ return $workspace;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function createAnotherEntity() {
+ $workspace = $this->entity->createDuplicate();
+ $workspace->id = 'layla_dupe';
+ $workspace->label = 'Layla_dupe';
+ $workspace->save();
+ return $workspace;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load($this->entity->getOwnerId());
+ return [
+ 'created' => [
+ $this->formatExpectedTimestampItemValues((int) $this->entity->getCreatedTime()),
+ ],
+ 'changed' => [
+ $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+ ],
+ 'id' => [
+ [
+ 'value' => 'layla',
+ ],
+ ],
+ 'label' => [
+ [
+ 'value' => 'Layla',
+ ],
+ ],
+ 'revision_id' => [
+ [
+ 'value' => 3,
+ ],
+ ],
+ 'uid' => [
+ [
+ 'target_id' => (int) $author->id(),
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/' . $author->id(),
+ ],
+ ],
+ 'target' => [
+ [
+ 'value' => 'live',
+ ],
+ ],
+ 'uuid' => [
+ [
+ 'value' => $this->entity->uuid()
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'id' => [
+ [
+ 'value' => static::$firstCreatedEntityId,
+ ],
+ ],
+ 'label' => [
+ [
+ 'value' => 'Running on faith',
+ ],
+ ],
+ 'target' => [
+ [
+ 'value' => 'local_workspace:stage',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getSecondNormalizedPostEntity() {
+ $normalized_post_entity = $this->getNormalizedPostEntity();
+ $normalized_post_entity['id'][0]['value'] = static::$secondCreatedEntityId;
+
+ return $normalized_post_entity;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPatchEntity() {
+ return [
+ 'label' => [
+ [
+ 'value' => 'Running on faith',
+ ],
+ ],
+ 'target' => [
+ [
+ 'value' => 'local_workspace:stage',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedUnauthorizedAccessMessage($method) {
+ if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
+ return parent::getExpectedUnauthorizedAccessMessage($method);
+ }
+
+ switch ($method) {
+ case 'GET':
+ return "The 'view any workspace' permission is required.";
+ break;
+ case 'POST':
+ return "The 'create workspace' permission is required.";
+ break;
+ case 'PATCH':
+ return "The 'edit any workspace' permission is required.";
+ break;
+ case 'DELETE':
+ return "The 'delete any workspace' permission is required.";
+ break;
+ }
+ return parent::getExpectedUnauthorizedAccessMessage($method);
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
new file mode 100644
index 0000000..5004f53
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for unauthenticated XML requests.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlAnonTest extends WorkspaceResourceTestBase {
+
+ use AnonResourceTestTrait;
+ use XmlEntityNormalizationQuirksTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'xml';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'text/xml; charset=UTF-8';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function testPatchPath() {
+ // Deserialization of the XML format is not supported.
+ $this->markTestSkipped();
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
new file mode 100644
index 0000000..ac6f3c5
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for XML requests with cookie authentication.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlBasicAuthTest extends WorkspaceResourceTestBase {
+
+ use BasicAuthResourceTestTrait;
+ use XmlEntityNormalizationQuirksTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['basic_auth'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'xml';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'text/xml; charset=UTF-8';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $auth = 'basic_auth';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function testPatchPath() {
+ // Deserialization of the XML format is not supported.
+ $this->markTestSkipped();
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
new file mode 100644
index 0000000..c8a23d1
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for XML requests.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlCookieTest extends WorkspaceResourceTestBase {
+
+ use CookieResourceTestTrait;
+ use XmlEntityNormalizationQuirksTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $format = 'xml';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $mimeType = 'text/xml; charset=UTF-8';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $auth = 'cookie';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function testPatchPath() {
+ // Deserialization of the XML format is not supported.
+ $this->markTestSkipped();
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
new file mode 100644
index 0000000..df33399
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * Tests access bypass permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceBypassTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+ use ContentTypeCreationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['node', 'user', 'block', 'workspace'];
+
+ /**
+ * Verifies that a user can edit anything in a workspace they own.
+ */
+ public function testBypassOwnWorkspace() {
+ $permissions = [
+ 'create workspace',
+ 'edit own workspace',
+ 'view own workspace',
+ 'bypass entity access own workspace',
+ ];
+
+ $this->createContentType(['type' => 'test', 'label' => 'Test']);
+ $this->setupWorkspaceSwitcherBlock();
+
+ $ditka = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($ditka);
+ $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+ $this->switchToWorkspace($bears);
+
+ // Now create a node in the Bears workspace, as the owner of that workspace.
+ $ditka_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
+ $ditka_bears_node_id = $ditka_bears_node->id();
+
+ // Editing both nodes should be possible.
+ $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+ $this->assertSession()->statusCodeEquals(200);
+
+ // Create a new user that should be able to edit anything in the Bears
+ // workspace.
+ $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view any workspace']));
+ $this->drupalLogin($lombardi);
+ $this->switchToWorkspace($bears);
+
+ // Because editor 2 has the bypass permission, he should be able to create
+ // and edit any node.
+ $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
new file mode 100644
index 0000000..b6b5468
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceCacheContext;
+
+/**
+ * Tests the workspace cache context.
+ *
+ * @group workspace
+ * @group Cache
+ */
+class WorkspaceCacheContextTest extends BrowserTestBase {
+
+ use AssertPageCacheContextsAndTagsTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['block', 'node', 'workspace'];
+
+ /**
+ * Tests the 'workspace' cache context.
+ */
+ public function testWorkspaceCacheContext() {
+ $this->dumpHeaders = TRUE;
+
+ $renderer = \Drupal::service('renderer');
+ $cache_contexts_manager = \Drupal::service("cache_contexts_manager");
+
+ // Check that the 'workspace' cache context is present when the module is
+ // installed.
+ $this->drupalGet('<front>');
+ $this->assertCacheContext('workspace');
+
+ $cache_context = new WorkspaceCacheContext(\Drupal::service('workspace.manager'));
+ $this->assertSame('live', $cache_context->getContext());
+
+ // Create a node and check that its render array contains the proper cache
+ // context.
+ $this->drupalCreateContentType(['type' => 'page']);
+ $node = $this->createNode();
+
+ // Get a fully built entity view render array.
+ $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+ // Render it so the default cache contexts are applied.
+ $renderer->renderRoot($build);
+ $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+ $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+ $this->assertTrue(in_array('[workspace]=live', $cid_parts, TRUE));
+
+ // Test that a cache entry is created.
+ $cid = implode(':', $cid_parts);
+ $bin = $build['#cache']['bin'];
+ $this->assertTrue($this->container->get('cache.' . $bin)->get($cid), 'The entity render element has been cached.');
+
+ // Switch to the 'stage' workspace and check that the correct workspace
+ // cache context is used.
+ $test_user = $this->drupalCreateUser(['view any workspace']);
+ $this->drupalLogin($test_user);
+
+ $stage = Workspace::load('stage');
+ $workspace_manager = \Drupal::service('workspace.manager');
+ $workspace_manager->setActiveWorkspace($stage);
+
+ $cache_context = new WorkspaceCacheContext($workspace_manager);
+ $this->assertSame('stage', $cache_context->getContext());
+
+ $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+ // Render it so the default cache contexts are applied.
+ $renderer->renderRoot($build);
+ $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+ $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+ $this->assertTrue(in_array('[workspace]=stage', $cid_parts, TRUE));
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php
new file mode 100644
index 0000000..1662eb6
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests concurrent edits in different workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceConcurrentEditingTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['block', 'node', 'workspace'];
+
+ /**
+ * Test switching workspace via the switcher block and admin page.
+ */
+ public function testSwitchingWorkspaces() {
+ $permissions = [
+ 'create workspace',
+ 'edit own workspace',
+ 'view own workspace',
+ 'bypass entity access own workspace',
+ ];
+
+ $mayer = $this->drupalCreateUser($permissions);
+ $this->drupalLogin($mayer);
+ $this->setupWorkspaceSwitcherBlock();
+
+ // Create a test node.
+ $this->createContentType(['type' => 'test', 'label' => 'Test']);
+ $test_node = $this->createNodeThroughUi('Test node', 'test');
+
+ // Check that the user can edit the node.
+ $page = $this->getSession()->getPage();
+ $page->hasField('title[0][value]');
+
+ // Create two workspaces.
+ $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures');
+ $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity');
+
+ // Edit the node in workspace 'vultures'.
+ $this->switchToWorkspace($vultures);
+ $this->drupalGet('/node/' . $test_node->id() . '/edit');
+ $page = $this->getSession()->getPage();
+ $page->fillField('Title', 'Test node - override');
+ $page->findButton('Save')->click();
+
+ // Check that the user can still edit the node in the same workspace.
+ $this->drupalGet('/node/' . $test_node->id() . '/edit');
+ $page = $this->getSession()->getPage();
+ $this->assertTrue($page->hasField('title[0][value]'));
+
+ // Switch to a different workspace and check that the user can not edit the
+ // node anymore.
+ $this->switchToWorkspace($gravity);
+ $this->drupalGet('/node/' . $test_node->id() . '/edit');
+ $page = $this->getSession()->getPage();
+ $this->assertFalse($page->hasField('title[0][value]'));
+ $page->hasContent('The content is being edited in the Vultures workspace.');
+
+ // Check that the node fails validation for API calls.
+ $violations = $test_node->validate();
+ $this->assertCount(1, $violations);
+ $this->assertEquals('The content is being edited in the <em class="placeholder">Vultures</em> workspace. As a result, your changes cannot be saved.', $violations->get(0)->getMessage());
+
+ // Switch to the Live workspace and check that the user still can not edit
+ // the node.
+ $live = Workspace::load('live');
+ $this->switchToWorkspace($live);
+ $this->drupalGet('/node/' . $test_node->id() . '/edit');
+ $page = $this->getSession()->getPage();
+ $this->assertFalse($page->hasField('title[0][value]'));
+ $page->hasContent('The content is being edited in the Vultures workspace.');
+
+ // Check that the node fails validation for API calls.
+ $violations = $test_node->validate();
+ $this->assertCount(1, $violations);
+ $this->assertEquals('The content is being edited in the <em class="placeholder">Vultures</em> workspace. As a result, your changes cannot be saved.', $violations->get(0)->getMessage());
+
+ // Deploy the changes from the 'Vultures' workspace and check that the node
+ // can be edited again in other workspaces.
+ $vultures->getRepositoryHandler()->push();
+ $this->switchToWorkspace($gravity);
+ $this->drupalGet('/node/' . $test_node->id() . '/edit');
+ $page = $this->getSession()->getPage();
+ $this->assertTrue($page->hasField('title[0][value]'));
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
new file mode 100644
index 0000000..3f95cd3
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspacePermissionsTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['workspace'];
+
+ /**
+ * Verifies that a user can create but not edit a workspace.
+ */
+ public function testCreateWorkspace() {
+ $editor = $this->drupalCreateUser([
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ ]);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor);
+ $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ // Now edit that same workspace; We shouldn't be able to do so, since
+ // we don't have edit permissions.
+ /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $etm */
+ $etm = \Drupal::service('entity_type.manager');
+ /** @var \Drupal\workspace\WorkspaceInterface $bears */
+ $entity_list = $etm->getStorage('workspace')->loadByProperties(['label' => 'Bears']);
+ $bears = current($entity_list);
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/edit");
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+ /**
+ * Verifies that a user can create and edit only their own workspace.
+ */
+ public function testEditOwnWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'edit own workspace',
+ ];
+
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+ $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ // Now edit that same workspace; We should be able to do so.
+ $bears = Workspace::load('bears');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/edit");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $page = $this->getSession()->getPage();
+ $page->fillField('label', 'Bears again');
+ $page->fillField('id', 'bears');
+ $page->findButton('Save')->click();
+ $page->hasContent('Bears again (bears)');
+
+ // Now login as a different user and ensure they don't have edit access,
+ // and vice versa.
+ $editor2 = $this->drupalCreateUser($permissions);
+
+ $this->drupalLogin($editor2);
+ $this->createWorkspaceThroughUi('Packers', 'packers');
+ $packers = Workspace::load('packers');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$packers->id()}/edit");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/edit");
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+ /**
+ * Verifies that a user can edit any workspace.
+ */
+ public function testEditAnyWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'edit own workspace',
+ ];
+
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+ $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ // Now edit that same workspace; We should be able to do so.
+ $bears = Workspace::load('bears');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/edit");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $page = $this->getSession()->getPage();
+ $page->fillField('label', 'Bears again');
+ $page->fillField('id', 'bears');
+ $page->findButton('Save')->click();
+ $page->hasContent('Bears again (bears)');
+
+ // Now login as a different user and ensure they don't have edit access,
+ // and vice versa.
+ $admin = $this->drupalCreateUser(array_merge($permissions, ['edit any workspace']));
+
+ $this->drupalLogin($admin);
+ $this->createWorkspaceThroughUi('Packers', 'packers');
+ $packers = Workspace::load('packers');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$packers->id()}/edit");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/edit");
+ $this->assertSession()->statusCodeEquals(200);
+ }
+
+ /**
+ * Verifies that a user can create and delete only their own workspace.
+ */
+ public function testDeleteOwnWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'delete own workspace',
+ ];
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+ $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ // Now try to delete that same workspace; We should be able to do so.
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/delete");
+ $this->assertSession()->statusCodeEquals(200);
+
+ // Now login as a different user and ensure they don't have edit access,
+ // and vice versa.
+ $editor2 = $this->drupalCreateUser($permissions);
+
+ $this->drupalLogin($editor2);
+ $packers = $this->createWorkspaceThroughUi('Packers', 'packers');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$packers->id()}/delete");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/delete");
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+ /**
+ * Verifies that a user can delete any workspace.
+ */
+ public function testDeleteAnyWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'delete own workspace',
+ ];
+ $editor1 = $this->drupalCreateUser($permissions);
+
+ // Login as a limited-access user and create a workspace.
+ $this->drupalLogin($editor1);
+ $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+
+ // Now edit that same workspace; We should be able to do so.
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/delete");
+ $this->assertSession()->statusCodeEquals(200);
+
+ // Now login as a different user and ensure they have delete access on both
+ // workspaces.
+ $admin = $this->drupalCreateUser(array_merge($permissions, ['delete any workspace']));
+
+ $this->drupalLogin($admin);
+ $packers = $this->createWorkspaceThroughUi('Packers', 'packers');
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$packers->id()}/delete");
+ $this->assertSession()->statusCodeEquals(200);
+
+ $this->drupalGet("/admin/config/workflow/workspace/manage/{$bears->id()}/delete");
+ $this->assertSession()->statusCodeEquals(200);
+
+ // Check that the default workspace can not be deleted, even by a user with
+ // the "delete any workspace" permission.
+ $this->drupalGet("/admin/config/workflow/workspace/manage/live/delete");
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
new file mode 100644
index 0000000..ede2382
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests workspace switching functionality.
+ *
+ * @group workspace
+ */
+class WorkspaceSwitcherTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['block', 'workspace'];
+
+ /**
+ * Test switching workspace via the switcher block and admin page.
+ */
+ public function testSwitchingWorkspaces() {
+ $permissions = [
+ 'create workspace',
+ 'edit own workspace',
+ 'view own workspace',
+ 'bypass entity access own workspace',
+ ];
+
+ $this->setupWorkspaceSwitcherBlock();
+
+ $mayer = $this->drupalCreateUser($permissions);
+ $this->drupalLogin($mayer);
+
+ $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures');
+ $this->switchToWorkspace($vultures);
+
+ $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity');
+
+ $this->drupalGet('/admin/config/workflow/workspace/manage/' . $gravity->id() . '/activate');
+
+ $this->assertSession()->statusCodeEquals(200);
+ $page = $this->getSession()->getPage();
+ $page->findButton('Confirm')->click();
+
+ $page->findLink($gravity->label());
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
new file mode 100644
index 0000000..dbf2d8b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test the workspace entity.
+ *
+ * @group workspace
+ */
+class WorkspaceTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['workspace'];
+
+ /**
+ * A test user.
+ *
+ * @var \Drupal\user\Entity\User
+ */
+ protected $editor1;
+
+ /**
+ * A test user.
+ *
+ * @var \Drupal\user\Entity\User
+ */
+ protected $editor2;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setUp() {
+ parent::setUp();
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'edit own workspace',
+ 'edit any workspace',
+ ];
+
+ $this->editor1 = $this->drupalCreateUser($permissions);
+ $this->editor2 = $this->drupalCreateUser($permissions);
+ }
+
+ /**
+ * Test creating a workspace with special characters.
+ */
+ public function testSpecialCharacters() {
+ $this->drupalLogin($this->editor1);
+
+ // Test a valid workspace name.
+ $this->createWorkspaceThroughUi('Workspace 1', 'a0_$()+-/');
+
+ // Test and invalid workspace name.
+ $this->drupalGet('/admin/config/workflow/workspace/add');
+ $this->assertSession()->statusCodeEquals(200);
+
+ $page = $this->getSession()->getPage();
+ $page->fillField('label', 'workspace2');
+ $page->fillField('id', 'A!"£%^&*{}#~@?');
+ $page->findButton('Save')->click();
+ $page->hasContent("This value is not valid");
+ }
+
+ /**
+ * Test changing the owner of a workspace.
+ */
+ public function testWorkspaceOwner() {
+ $this->drupalLogin($this->editor1);
+
+ $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+ 'id' => 'test_workspace',
+ 'label' => 'Test workspace',
+ ], 'Save');
+
+ $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+ $test_workspace = $storage->load('test_workspace');
+ $this->assertEquals($this->editor1->id(), $test_workspace->getOwnerId());
+
+ $this->drupalPostForm('/admin/config/workflow/workspace/manage/test_workspace/edit', [
+ 'uid[0][target_id]' => $this->editor2->getUsername(),
+ ], 'Save');
+
+ $test_workspace = $storage->loadUnchanged('test_workspace');
+ $this->assertEquals($this->editor2->id(), $test_workspace->getOwnerId());
+ }
+
+ /**
+ * Tests that editing a workspace creates a new revision.
+ */
+ public function testWorkspaceFormRevisions() {
+ $this->drupalLogin($this->editor1);
+ $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+
+ // The current live workspace entity should be revision 1.
+ $live_workspace = $storage->load('live');
+ $this->assertEquals('1', $live_workspace->getRevisionId());
+
+ // Re-save the live workspace via the UI to create revision 3.
+ $this->drupalPostForm($live_workspace->url('edit-form'), [], 'Save');
+ $live_workspace = $storage->loadUnchanged('live');
+ $this->assertEquals('3', $live_workspace->getRevisionId());
+ }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
new file mode 100644
index 0000000..41f337f
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\block\Traits\BlockCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceInterface;
+
+/**
+ * Utility methods for use in BrowserTestBase tests.
+ *
+ * This trait will not work if not used in a child of BrowserTestBase.
+ */
+trait WorkspaceTestUtilities {
+
+ use BlockCreationTrait;
+
+ /**
+ * Loads a single entity by its label.
+ *
+ * The UI approach to creating an entity doesn't make it easy to know what
+ * the ID is, so this lets us make paths for an entity after it's created.
+ *
+ * @param string $type
+ * The type of entity to load.
+ * @param string $label
+ * The label of the entity to load.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface
+ * The entity.
+ */
+ protected function getOneEntityByLabel($type, $label) {
+ /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
+ $entity_type_manager = \Drupal::service('entity_type.manager');
+ $property = $entity_type_manager->getDefinition($type)->getKey('label');
+ $entity_list = $entity_type_manager->getStorage($type)->loadByProperties([$property => $label]);
+ $entity = current($entity_list);
+ if (!$entity) {
+ $this->fail("No {$type} entity named {$label} found.");
+ }
+
+ return $entity;
+ }
+
+ /**
+ * Creates a new Workspace through the UI.
+ *
+ * @param string $label
+ * The label of the workspace to create.
+ * @param string $id
+ * The ID of the workspace to create.
+ *
+ * @return \Drupal\workspace\WorkspaceInterface
+ * The workspace that was just created.
+ */
+ protected function createWorkspaceThroughUi($label, $id) {
+ $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+ 'id' => $id,
+ 'label' => $label,
+ ], 'Save');
+
+ $this->getSession()->getPage()->hasContent("$label ($id)");
+
+ return Workspace::load($id);
+ }
+
+ /**
+ * Adds the workspace switcher block to the site.
+ *
+ * This is necessary for switchToWorkspace() to function correctly.
+ */
+ protected function setupWorkspaceSwitcherBlock() {
+ // Add the block to the sidebar.
+ $this->placeBlock('workspace_switcher', [
+ 'id' => 'workspaceswitcher',
+ 'region' => 'sidebar_first',
+ 'label' => 'Workspace switcher',
+ ]);
+
+ // Confirm the block shows on the front page.
+ $this->drupalGet('<front>');
+ $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 0000000..a0ed21b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests uninstalling the Workspace module.
+ *
+ * @group workspace
+ */
+class WorkspaceUninstallTest extends BrowserTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $profile = 'standard';
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['workspace'];
+
+ /**
+ * Tests deleting workspace entities and uninstalling Workspace module.
+ */
+ public function testUninstallingWorkspace() {
+ $this->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..e29f7b0
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceViewTest extends BrowserTestBase {
+
+ use WorkspaceTestUtilities;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['workspace', 'workspace'];
+
+ /**
+ * Verifies that a user can view their own workspace.
+ */
+ public function testViewOwnWorkspace() {
+ $permissions = [
+ 'access administration pages',
+ 'administer site configuration',
+ 'create workspace',
+ 'edit own workspace',
+ 'view own 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. 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 0000000..7dd4626
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests access on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceAccessTest extends KernelTestBase {
+
+ use UserCreationTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'user',
+ 'system',
+ 'workspace',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->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 0000000..69c13ac
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests CRUD operations for workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceCRUDTest extends KernelTestBase {
+
+ use UserCreationTrait;
+ use NodeCreationTrait;
+ use ContentTypeCreationTrait;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * The state service.
+ *
+ * @var \Drupal\Core\State\StateInterface
+ */
+ protected $state;
+
+ /**
+ * The workspace replication manager.
+ *
+ * @var \Drupal\workspace\WorkspaceManagerInterface
+ */
+ protected $workspaceManager;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'user',
+ 'system',
+ 'workspace',
+ 'field',
+ 'filter',
+ 'node',
+ 'text',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->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 0000000..eae90d2
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
@@ -0,0 +1,677 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\entity_test\Entity\EntityTestMulRev;
+use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\views\Tests\ViewResultAssertionTrait;
+use Drupal\views\Views;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests a complete deployment scenario across different workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceIntegrationTest extends KernelTestBase {
+
+ use ContentTypeCreationTrait;
+ use EntityReferenceTestTrait;
+ use NodeCreationTrait;
+ use UserCreationTrait;
+ use ViewResultAssertionTrait;
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * An array of test workspaces, keyed by workspace ID.
+ *
+ * @var \Drupal\workspace\WorkspaceInterface[]
+ */
+ protected $workspaces = [];
+
+ /**
+ * Creation timestamp that should be incremented for each new entity.
+ *
+ * @var int
+ */
+ protected $createdTimestamp;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'entity_test',
+ 'field',
+ 'filter',
+ 'node',
+ 'text',
+ 'user',
+ 'system',
+ 'views',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->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 0000000..1acf66f
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\rest\Entity\RestResourceConfig;
+use Drupal\rest\RestResourceConfigInterface;
+
+/**
+ * Tests REST module with internal workspace entity types.
+ *
+ * @group workspace
+ */
+class WorkspaceInternalResourceTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['user', 'serialization', 'rest', 'workspace'];
+
+ /**
+ * Tests enabling workspace associations for REST throws an exception.
+ *
+ * @see \Drupal\workspace\Entity\WorkspaceAssociation
+ */
+ public function testCreateWorkspaceAssociationResource() {
+ $this->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 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..45cd59c
--- /dev/null
+++ b/core/modules/workspace/workspace.install
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * @file
+ * Contains install, update and uninstall functions for the workspace module.
+ */
+
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Implements hook_install().
+ */
+function workspace_install() {
+ // Set the owner of these default workspaces to be first user which which has
+ // the 'administrator' role. This way we avoid hard coding user ID 1 for sites
+ // that prefer to not give it any special meaning.
+ $admin_roles = \Drupal::entityTypeManager()->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 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..dd2ef38
--- /dev/null
+++ b/core/modules/workspace/workspace.module
@@ -0,0 +1,251 @@
+<?php
+
+/**
+ * @file
+ * Provides full-site preview functionality for content staging.
+ */
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\views\Plugin\views\query\QueryPluginBase;
+use Drupal\views\ViewExecutable;
+use Drupal\workspace\EntityAccess;
+use Drupal\workspace\EntityOperations;
+use Drupal\workspace\EntityTypeInfo;
+use Drupal\workspace\ViewsQueryAlter;
+
+/**
+ * Implements hook_help().
+ */
+function workspace_help($route_name, RouteMatchInterface $route_match) {
+ switch ($route_name) {
+ // Main module help for the workspace module.
+ case 'help.page.workspace':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . 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 <a href=":workspace">online documentation for the Workspace module</a>.', [':workspace' => 'https://www.drupal.org/node/2824024']) . '</p>';
+ 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 0000000..4856449
--- /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 0000000..70a6a22
--- /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 0000000..13d67ae
--- /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