diff --git a/core/composer.json b/core/composer.json index 1f60805..1f3a895 100644 --- a/core/composer.json +++ b/core/composer.json @@ -153,7 +153,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\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 @@ public function testInstallUninstall() { $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 4d8f30d..aacce4f 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 @@ use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Logger\RfcLogLevel; +use Drupal\workspace\Entity\Workspace; /** * Install/uninstall core module and confirm table creation/deletion. @@ -147,6 +148,12 @@ public function testInstallUninstall() { $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..81ee1f9 --- /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.toolbar-tab { + float: right; /* LTR */ + background-color: #e09600; +} +[dir="rtl"] .toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab { + float: left; +} +.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab.is-live { + 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 #dddddd; /* LTR */ +} +[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li + li { + border-left: 0 none; + border-right: 1px solid #dddddd; +} +.toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child { + border-right: 1px solid #dddddd; /* LTR */ +} +[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child { + border-left: 1px solid #dddddd; +} 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 @@ + 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 @@ +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 UNIX timestamp of when the workspace has been 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 getRepositoryHandlerPlugin() { + if (($target = $this->target->value) && $target !== RepositoryHandlerInterface::EMPTY_VALUE) { + $configuration = [ + 'source' => $this->id(), + 'target' => $target, + ]; + return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance($target, $configuration); + } + } + + /** + * {@inheritdoc} + */ + public function isDefaultWorkspace() { + return $this->id() === WorkspaceManager::DEFAULT_WORKSPACE; + } + + /** + * {@inheritdoc} + */ + public function setCreatedTime($created) { + $this->set('created', (int) $created); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getStartTime() { + return $this->get('created')->value; + } + + /** + * {@inheritdoc} + */ + public function getOwner() { + return $this->get('uid')->entity; + } + + /** + * {@inheritdoc} + */ + public function setOwner(UserInterface $account) { + $this->set('uid', $account->id()); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getOwnerId() { + return $this->get('uid')->target_id; + } + + /** + * {@inheritdoc} + */ + public function setOwnerId($uid) { + $this->set('uid', $uid); + return $this; + } + + /** + * {@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[WorkspaceManager::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..099f7a2 --- /dev/null +++ b/core/modules/workspace/src/Entity/WorkspaceAssociation.php @@ -0,0 +1,76 @@ +setLabel(new TranslatableMarkup('workspace')) + ->setDescription(new TranslatableMarkup('The workspace of the referenced content.')) + ->setSetting('target_type', 'workspace') + ->setRequired(TRUE) + ->setRevisionable(TRUE) + ->addConstraint('workspace', []); + + $fields['content_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['content_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['content_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..58733f7 --- /dev/null +++ b/core/modules/workspace/src/EntityAccess.php @@ -0,0 +1,170 @@ +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->entityTypeCanBelongToWorkspaces($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->entityTypeCanBelongToWorkspaces($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, ie, 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::allowedIfHasPermission($account, 'bypass entity access workspace ' . $active_workspace->id())->addCacheableDependency($active_workspace) + ->orIf( + AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id())->cachePerUser()->addCacheableDependency($active_workspace) + ->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace')) + ); + } + + /** + * Returns an array of workspace-specific permissions. + * + * @return array + * The workspace permissions. + */ + public function workspacePermissions() { + $perms = []; + + foreach ($this->entityTypeManager->getStorage('workspace')->loadMultiple() as $workspace) { + /** @var \Drupal\workspace\WorkspaceInterface $workspace */ + $perms += $this->createWorkspaceBypassPermission($workspace); + } + + return $perms; + } + + /** + * Derives the delete permission for a specific workspace. + * + * @param \Drupal\workspace\WorkspaceInterface $workspace + * The workspace from which to derive the permission. + * + * @return array + * A single-item array with the permission to define. + */ + protected function createWorkspaceBypassPermission(WorkspaceInterface $workspace) { + $perms['bypass entity access workspace ' . $workspace->id()] = [ + 'title' => $this->t('Bypass content entity access in %workspace workspace', ['%workspace' => $workspace->label()]), + 'description' => $this->t('Allow all Edit/Update/Delete permissions for all content in the %workspace workspace', ['%workspace' => $workspace->label()]), + 'restrict access' => TRUE, + ]; + + return $perms; + } + +} diff --git a/core/modules/workspace/src/EntityOperations.php b/core/modules/workspace/src/EntityOperations.php new file mode 100644 index 0000000..57fefe4 --- /dev/null +++ b/core/modules/workspace/src/EntityOperations.php @@ -0,0 +1,254 @@ +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->entityTypeCanBelongToWorkspaces($this->entityTypeManager->getDefinition($entity_type_id)) + || (($active_workspace = $this->workspaceManager->getActiveWorkspace()) && $active_workspace->isDefaultWorkspace())) { + return; + } + + // Get a list of revision IDs for entities that have a revision set for the + // current active workspace. If an entity has multiple revisions set for a + // workspace, only the one with the highest ID is returned. + $entity_ids = array_keys($entities); + $max_revision_id = 'max_content_entity_revision_id'; + $results = $this->entityTypeManager + ->getStorage('workspace_association') + ->getAggregateQuery() + ->allRevisions() + ->aggregate('content_entity_revision_id', 'MAX', NULL, $max_revision_id) + ->groupBy('content_entity_id') + ->condition('content_entity_type_id', $entity_type_id) + ->condition('content_entity_id', $entity_ids, 'IN') + ->condition('workspace', $active_workspace->id(), '=') + ->execute(); + + // Since hook_entity_load() is called on both regular entity load as well as + // entity revision load, we need to prevent infinite recursion by checking + // whether the default revisions were already swapped with the workspace + // revision. + // @todo This recursion protection should be removed when + // https://www.drupal.org/project/drupal/issues/2928888 is resolved. + if ($results) { + foreach ($results as $key => $result) { + if ($entities[$result['content_entity_id']]->getRevisionId() == $result[$max_revision_id]) { + unset($results[$key]); + } + } + } + + if ($results) { + /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */ + $storage = $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->entityTypeCanBelongToWorkspaces($entity->getEntityType()) + || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { + return; + } + + // Force a new revision if the entity is not replicating. + if (!$entity->isNew() && !isset($entity->_isReplicating)) { + $entity->setNewRevision(TRUE); + + // All entities in the non-default workspace are pending revisions, + // regardless of their publishing status. This means that when creating + // a published pending revision in a non-default workspace it will also be + // a published pending revision in the default workspace, however, it will + // become the default revision only when it is replicated to the default + // workspace. + $entity->isDefaultRevision(FALSE); + } + + // When a new published entity is inserted in a non-default workspace, we + // actually want two revisions to be saved: + // - An unpublished default revision in the default ('live') workspace. + // - A published pending revision in the current workspace. + if ($entity->isNew() && $entity->isPublished()) { + // Keep track of the publishing status for workspace_entity_insert() and + // unpublish the default revision. + $entity->_initialPublished = TRUE; + $entity->setUnpublished(); + } + } + + /** + * 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->entityTypeCanBelongToWorkspaces($entity->getEntityType()) + || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { + 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. + 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->entityTypeCanBelongToWorkspaces($entity->getEntityType()) + || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { + return; + } + + // Only track new revisions. + /** @var \Drupal\Core\Entity\ContentEntityInterface $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. + if (!$entity->isNew()) { + $workspace_associations = $this->entityTypeManager + ->getStorage('workspace_association') + ->loadByProperties([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_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 = WorkspaceAssociation::create([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_entity_id' => $entity->id(), + ]); + } + + // Add the revision ID and the workspace ID. + $workspace_association->set('content_entity_revision_id', $entity->getRevisionId()); + $workspace_association->set('workspace', $this->workspaceManager->getActiveWorkspace()->id()); + + // Save without updating the tracked content entity. + $workspace_association->save(); + } + +} diff --git a/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php new file mode 100644 index 0000000..c7b7429 --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php @@ -0,0 +1,53 @@ +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..19479f1 --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/Query.php @@ -0,0 +1,62 @@ +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.content_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 @@ +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..9feae9b --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/QueryFactory.php @@ -0,0 +1,53 @@ +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..6606106 --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/QueryTrait.php @@ -0,0 +1,72 @@ +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->entityTypeCanBelongToWorkspaces($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.content_entity_type_id = '{$this->entityTypeId}' AND %alias.content_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..ed0eb01 --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/Tables.php @@ -0,0 +1,154 @@ +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')) { + $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 ($active_workspace_id) { + $this->sqlQuery->addMetaData('all_revisions', FALSE); + } + + 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.content_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->entityTypeCanBelongToWorkspaces($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.content_entity_type_id = '$entity_type_id' AND %alias.content_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..8cea472 --- /dev/null +++ b/core/modules/workspace/src/EntityTypeInfo.php @@ -0,0 +1,113 @@ +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->entityTypeCanBelongToWorkspaces($entity_type)) { + $entity_type->addConstraint('EntityWorkspaceConflict'); + } + } + } + + /** + * 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) { + $entity = $form_object->getEntity(); + + if ($this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())) { + /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */ + $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); + if ($workspace_ids = $workspace_association_storage->isEntityTracked($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/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php new file mode 100644 index 0000000..4da5cad --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php @@ -0,0 +1,117 @@ +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 is now the active workspace.", ['@workspace' => $this->entity->label()])); + $form_state->setRedirectUrl($this->entity->toUrl('collection')); + } + catch (\Exception $e) { + watchdog_exception('workspace', $e); + $this->messenger->addError($e->getMessage()); + } + } + +} diff --git a/core/modules/workspace/src/Form/WorkspaceDeleteForm.php b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php new file mode 100644 index 0000000..43311b2 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php @@ -0,0 +1,42 @@ +entity->getRepositoryHandlerPlugin()->getSourceRevisionDifference(); + $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..7bbf345 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceDeployForm.php @@ -0,0 +1,200 @@ +messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $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->getRepositoryHandlerPlugin(); + + // We can not push or pull if we do not have a valid target. + if (!$repository_handler) { + throw new HttpException(500, 'The specified repository handler plugin does not exist.'); + } + + $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->getSourceRevisionDifference()) { + $total_count = count($source_rev_diff, COUNT_RECURSIVE) - count($source_rev_diff); + $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->getTargetRevisionDifference()) { + $total_count = count($target_rev_diff, COUNT_RECURSIVE) - count($target_rev_diff); + $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); + + $repositoy_handler = $this->entity->getRepositoryHandlerPlugin(); + + 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' => $repositoy_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' => $repositoy_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->getRepositoryHandlerPlugin()->push(); + $this->messenger->addMessage($this->t('Successful deployment.')); + } + catch (\Exception $e) { + watchdog_exception('workspace', $e); + $this->messenger->addMessage($this->t('Deployment error'), '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->getRepositoryHandlerPlugin()->pull(); + $this->messenger->addMessage($this->t('Refresh successful.')); + } + catch (\Exception $e) { + watchdog_exception('workspace', $e); + $this->messenger->addMessage($this->t('refresh error'), '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..cc0b85c --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceForm.php @@ -0,0 +1,158 @@ +messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $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(''); + $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..4e21390 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php @@ -0,0 +1,144 @@ +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 validateForm(array &$form, FormStateInterface $form_state) { + $id = $form_state->getValue('workspace_id'); + + // Ensure the workspace by that ID exists. + if (!$this->workspaceStorage->load($id)) { + $form_state->setErrorByName('workspace_id', $this->t('This workspace does not exist.')); + } + } + + /** + * {@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 is now the active workspace.", ['@workspace' => $workspace->label()])); + $form_state->setRedirect(''); + } + catch (\Exception $e) { + watchdog_exception('workspace', $e); + $this->messenger->addError($e->getMessage()); + } + } + +} diff --git a/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php new file mode 100644 index 0000000..e93d0c6 --- /dev/null +++ b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php @@ -0,0 +1,69 @@ +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' => WorkspaceManager::DEFAULT_WORKSPACE, + 'label' => Unicode::ucwords(WorkspaceManager::DEFAULT_WORKSPACE), + ]); + $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 @@ +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 @@ +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('Drupal\workspace\Form\WorkspaceSwitcherForm'), + '#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..daaee46 --- /dev/null +++ b/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php @@ -0,0 +1,196 @@ +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 { + foreach ($this->getSourceRevisionDifference() as $entity_type_id => $revision_difference) { + $entity_revisions = $this->entityTypeManager->getStorage($entity_type_id) + ->loadMultipleRevisions(array_keys($revision_difference)); + /** @var \Drupal\Core\Entity\ContentEntityInterface|\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. + $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 getTargetRevisionDifference() { + $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 getSourceRevisionDifference() { + // Get the Workspace association revisions which haven't been pushed yet. + return $this->workspaceAssociationStorage->getTrackedEntities($this->source); + } + +} 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 @@ +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 */ + if (!isset($value)) { + return; + } + + $count = $this->workspaceAssociationStorage + ->getQuery() + ->allRevisions() + ->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..a8ebb0a --- /dev/null +++ b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php @@ -0,0 +1,20 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.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'); + + if ($workspace_ids = $workspace_association_storage->isEntityTracked($entity, FALSE)) { + // 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 @@ +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..6e49fd8 --- /dev/null +++ b/core/modules/workspace/src/RepositoryHandlerInterface.php @@ -0,0 +1,102 @@ +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); + } + +} diff --git a/core/modules/workspace/src/ViewsQueryAlter.php b/core/modules/workspace/src/ViewsQueryAlter.php new file mode 100644 index 0000000..6da6ae6 --- /dev/null +++ b/core/modules/workspace/src/ViewsQueryAlter.php @@ -0,0 +1,423 @@ +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->entityTypeCanBelongToWorkspaces($entity_type_definitions[$entity_type_id])) { + $this->alterQueryForEntityType($view, $query, $entity_type_definitions[$entity_type_id]); + } + } + } + + /** + * Alters the entity type tables for a Views query. + * + * @param \Drupal\views\ViewExecutable $view + * The view object about to be processed. + * @param \Drupal\views\Plugin\views\query\Sql $query + * The query plugin object for the query. + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + */ + protected function alterQueryForEntityType(ViewExecutable $view, Sql $query, EntityTypeInterface $entity_type) { + // This is only called after we determined that this entity type is involved + // in the query, and that a non-default workspace is in use. + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $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.content_entity_revision_id, $relationship.$revision_field)"; + + // Update the join and the table info to our new table name, and to join + // on the revision key. + $table_info['table'] = $new_table_name; + $table_info['join']->table = $new_table_name; + $table_info['join']->field = 'revision_id'; + + // Finally, if we added the 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, + // $query->count_field. + } + + /** + * 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' => 'content_entity_id', + 'left_table' => $relationship, + 'left_field' => $table_data['table']['base']['field'], + 'extra' => [ + [ + 'field' => 'content_entity_type_id', + 'value' => $entity_type_id, + ], + [ + 'field' => 'workspace', + 'value' => $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.content_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. This uses array_slice() to reconstruct + * the table queue of the query. + * + * @param \Drupal\views\Plugin\views\query\Sql $query + * The SQL query object. + * @param string $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..838eb58 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceAccessControlHandler.php @@ -0,0 +1,58 @@ +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 any workspace at all. + $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 @@ +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, $group = TRUE) { + $table = $all_revisions ? $this->getRevisionTable() : $this->getBaseTable(); + $query = $this->database->select($table, 'base_table'); + $query + ->fields('base_table', ['content_entity_type_id', 'content_entity_id', 'content_entity_revision_id']) + ->orderBy('content_entity_revision_id', 'ASC') + ->condition('workspace', $workspace_id); + + $tracked_revisions = []; + foreach ($query->execute() as $record) { + if ($group) { + $tracked_revisions[$record->content_entity_type_id][$record->content_entity_revision_id] = $record->content_entity_id; + } + else { + $tracked_revisions[] = [ + 'entity_type_id' => $record->content_entity_type_id, + 'revision_id' => $record->content_entity_revision_id, + 'entity_id' => $record->content_entity_id, + ]; + } + } + + return $tracked_revisions; + } + + /** + * {@inheritdoc} + */ + public function isEntityTracked(EntityInterface $entity) { + $query = $this->database->select($this->getBaseTable(), 'base_table'); + $query + ->fields('base_table', ['workspace']) + ->condition('content_entity_type_id', $entity->getEntityTypeId()) + ->condition('content_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..b08c035 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php @@ -0,0 +1,54 @@ +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 @@ +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'] = $entity->label() . ' (' . $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->getRepositoryHandlerPlugin()) { + $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..bdba834 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManager.php @@ -0,0 +1,256 @@ +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 entityTypeCanBelongToWorkspaces(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->entityTypeCanBelongToWorkspaces($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', ['%workspace_label' => $workspace->label()]); + 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 purgeDeletedWorkspacesBatch() { + $deleted_workspace_ids = $this->state->get('workspace.deleted', []); + $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 = $workspace_association_storage + ->getQuery() + ->allRevisions() + ->accessCheck(FALSE) + ->condition('workspace', $workspace_id) + ->sort('revision_id', 'ASC') + ->range(0, $batch_size) + ->execute(); + + 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->content_entity_type_id->value); + // Delete the associated entity revision. + if ($entity = $associated_entity_storage->loadRevision($workspace_association->content_entity_revision_id->value)) { + if ($entity->isDefaultRevision()) { + $entity->delete(); + } + else { + $associated_entity_storage->deleteRevision($workspace_association->content_entity_revision_id->value); + } + } + + // Delete the workspace_association revision. + if ($workspace_association->isDefaultRevision()) { + $workspace_association->delete(); + } + else { + $workspace_association_storage->deleteRevision($workspace_association->getRevisionId()); + } + } + } + + // Remove the deleted workspace ID entry from state if all its associated + // entities have been purged. + if (!$workspace_association_ids || ($workspace_association_ids && count($workspace_association_ids) < $batch_size)) { + unset($deleted_workspace_ids[$workspace_id]); + $this->state->set('workspace.deleted', $deleted_workspace_ids); + } + } + +} diff --git a/core/modules/workspace/src/WorkspaceManagerInterface.php b/core/modules/workspace/src/WorkspaceManagerInterface.php new file mode 100644 index 0000000..0467089 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManagerInterface.php @@ -0,0 +1,52 @@ +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 @@ +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->getStartTime()), + ], + '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() + ], + ], + 'revision_default' => [ + [ + 'value' => TRUE, + ], + ], + ]; + } + + /** + * {@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 @@ +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 @@ +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 @@ +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..37e3fbe --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php @@ -0,0 +1,121 @@ +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); + + $this->createNodeThroughUi('Vanilla node', 'test'); + + $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(); + + // Create a new user that should be able to edit anything in the Bears + // workspace. + $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view any workspace', 'bypass entity access workspace ' . $bears->id()])); + $this->drupalLogin($lombardi); + $this->switchToWorkspace($bears); + + // Because Lombardi 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(200); + + $lombardi_bears_node = $this->createNodeThroughUi('Lombardi Bears node', 'test'); + $lombardi_bears_node_id = $lombardi_bears_node->id(); + + $this->drupalLogin($ditka); + $this->switchToWorkspace($bears); + + $this->drupalGet('/node/' . $lombardi_bears_node_id . '/edit'); + $this->assertSession()->statusCodeEquals(403); + + // Create a new user that should NOT be able to edit anything in the Bears + // workspace. + $belichick = $this->drupalCreateUser(array_merge($permissions, ['view any workspace'])); + $this->drupalLogin($belichick); + $this->switchToWorkspace($bears); + + $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit'); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * 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 @@ +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(''); + $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..396aa20 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php @@ -0,0 +1,97 @@ +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 Vultures 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 Vultures 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->getRepositoryHandlerPlugin()->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..0afbb46 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php @@ -0,0 +1,209 @@ +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/{$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/{$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/{$packers->id()}/edit"); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet("/admin/config/workflow/workspace/{$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/{$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/{$packers->id()}/edit"); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet("/admin/config/workflow/workspace/{$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/{$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/{$packers->id()}/delete"); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet("/admin/config/workflow/workspace/{$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/{$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/{$packers->id()}/delete"); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet("/admin/config/workflow/workspace/{$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/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..fda4731 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php @@ -0,0 +1,51 @@ +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/' . $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..97b487f --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php @@ -0,0 +1,112 @@ +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/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 @@ +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(''); + $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 @@ +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..6517c5d --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php @@ -0,0 +1,100 @@ +drupalCreateUser($permissions); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($editor1); + $this->createWorkspaceThroughUi('Bears', 'bears'); + + $bears = Workspace::load('bears'); + + // Now login as a different user and create a workspace. + $editor2 = $this->drupalCreateUser($permissions); + + $this->drupalLogin($editor2); + $this->createWorkspaceThroughUi('Packers', 'packers'); + + $packers = Workspace::load('packers'); + + // Load the activate form for the Bears workspace. It should fail because + // the workspace belongs to someone else. + $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate"); + $this->assertSession()->statusCodeEquals(403); + + // But editor 2 should be able to activate the Packers workspace. + $this->drupalGet("admin/config/workflow/workspace/{$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/{$bears->id()}/activate"); + + $this->assertSession()->statusCodeEquals(200); + + // But editor 2 should be able to activate the Packers workspace. + $this->drupalGet("admin/config/workflow/workspace/{$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..e4cd10d --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php @@ -0,0 +1,81 @@ +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(); + $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..345cd4d --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php @@ -0,0 +1,191 @@ +installSchema('system', ['key_value_expire', 'sequences']); + $this->installSchema('node', ['node_access']); + + $this->installEntitySchema('workspace'); + $this->installEntitySchema('workspace_association'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + + $this->installConfig(['filter', 'node', 'system']); + + $this->createContentType(['type' => 'page']); + + $this->entityTypeManager = \Drupal::entityTypeManager(); + $this->state = \Drupal::state(); + $this->workspaceManager = \Drupal::service('workspace.manager'); + } + + /** + * Tests the deletion of workspaces. + */ + public function testDeletingWorkspaces() { + $admin = $this->createUser([ + 'administer nodes', + 'create workspace', + 'view any workspace', + 'edit any workspace', + 'delete any workspace', + ]); + $this->setCurrentUser($admin); + + /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */ + $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); + /** @var \Drupal\node\NodeStorageInterface $node_storage */ + $node_storage = $this->entityTypeManager->getStorage('node'); + + // Create a workspace with a very small number of associated node revisions. + $workspace_1 = Workspace::create([ + 'id' => 'gibbon', + 'label' => 'Gibbon', + ]); + $workspace_1->save(); + $this->workspaceManager->setActiveWorkspace($workspace_1); + + $workspace_1_node_1 = $this->createNode(['status' => FALSE]); + $workspace_1_node_2 = $this->createNode(['status' => FALSE]); + for ($i = 0; $i < 4; $i++) { + $workspace_1_node_1->setNewRevision(TRUE); + $workspace_1_node_1->save(); + + $workspace_1_node_2->setNewRevision(TRUE); + $workspace_1_node_2->save(); + } + + // The workspace should have 10 associated node revisions, 5 for each node. + $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE, FALSE); + $this->assertCount(10, $associated_revisions); + + // 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, FALSE); + $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, FALSE); + $this->assertCount(60, $associated_revisions); + + // 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, FALSE); + $this->assertCount(10, $associated_revisions); + + $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, FALSE); + $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..f0d9d64 --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php @@ -0,0 +1,679 @@ +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']->getRepositoryHandlerPlugin(); + + // Check which revisions need to be pushed. + $expected = [ + 'node' => [ + 3 => 1, + 4 => 2, + 5 => 3, + 7 => 4, + ], + ]; + $this->assertEquals($expected, $stage_repository_handler->getSourceRevisionDifference()); + + $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->getSourceRevisionDifference()); + } + + /** + * Tests the Entity Query relationship API with workspaces. + */ + public function testEntityQueryRelationship() { + $this->initializeWorkspaceModule(); + + // Add an entity reference field that targets 'entity_test_mulrev' entities. + $this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrev'); + + // Add an entity reference field that targets 'node' entities so we can test + // references to the same base tables. + $this->createEntityReferenceField('node', 'page', 'field_test_node', 'Test node reference', 'node'); + + $this->switchToWorkspace('live'); + $node_1 = $this->createNode([ + 'title' => 'live node 1' + ]); + $entity_test = EntityTestMulRev::create([ + 'name' => 'live entity_test_mulrev', + 'non_rev_field' => 'live non-revisionable value', + ]); + $entity_test->save(); + + $node_2 = $this->createNode([ + 'title' => 'live node 2', + 'field_test_entity' => $entity_test->id(), + 'field_test_node' => $node_1->id(), + ]); + + // Switch to the 'stage' workspace and change some values for the referenced + // entities. + $this->switchToWorkspace('stage'); + $node_1->title->value = 'stage node 1'; + $node_1->save(); + + $node_2->title->value = 'stage node 2'; + $node_2->save(); + + $entity_test->name->value = 'stage entity_test_mulrev'; + $entity_test->non_rev_field->value = 'stage non-revisionable value'; + $entity_test->save(); + + // Make sure that we're requesting the default revision. + $query = $this->entityTypeManager->getStorage('node')->getQuery(); + $query->currentRevision(); + + $query + // Check a condition on the revision data table. + ->condition('title', 'stage node 2') + // Check a condition on the revision table. + ->condition('revision_uid', $node_2->getRevisionUserId()) + // Check a condition on the data table. + ->condition('type', $node_2->bundle()) + // Check a condition on the base table. + ->condition('uuid', $node_2->uuid()); + + // Add conditions for a reference to the same entity type. + $query + // Check a condition on the revision data table. + ->condition('field_test_node.entity.title', 'stage node 1') + // Check a condition on the revision table. + ->condition('field_test_node.entity.revision_uid', $node_1->getRevisionUserId()) + // Check a condition on the data table. + ->condition('field_test_node.entity.type', $node_1->bundle()) + // Check a condition on the base table. + ->condition('field_test_node.entity.uuid', $node_1->uuid()); + + // Add conditions for a reference to a different entity type. + $query + // Check a condition on the revision data table. + ->condition('field_test_entity.entity.name', 'stage entity_test_mulrev') + // Check a condition on the data table. + ->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value') + // Check a condition on the base table. + ->condition('field_test_entity.entity.uuid', $entity_test->uuid()); + + $result = $query->execute(); + $this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result); + } + + /** + * Checks entity load, entity queries and views results for a test scenario. + * + * @param array $expected + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type that is being tested. + */ + protected function assertWorkspaceStatus(array $expected, $entity_type_id) { + $expected = $this->flattenExpectedValues($expected, $entity_type_id); + + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + foreach ($expected as $workspace_id => $expected_values) { + $this->switchToWorkspace($workspace_id); + + // Check that default revisions are swapped with the workspace revision. + $this->assertEntityLoad($expected_values, $entity_type_id); + + // Check that non-default revisions are not changed. + $this->assertEntityRevisionLoad($expected_values, $entity_type_id); + + // Check that entity queries return the correct results. + $this->assertEntityQuery($expected_values, $entity_type_id); + + // Check that the 'Frontpage' view only shows published content that is + // also considered as the default revision in the given workspace. + $expected_frontpage = array_filter($expected_values, function ($expected_value) { + return $expected_value['status'] === TRUE && $expected_value['default_revision'] === TRUE; + }); + // The 'Frontpage' view will output nodes in reverse creation order. + usort($expected_frontpage, function ($a, $b) { + return $b['nid'] - $a['nid']; + }); + $view = Views::getView('frontpage'); + $view->execute(); + $this->assertIdenticalResultset($view, $expected_frontpage, ['nid' => 'nid']); + + $rendered_view = $view->render('page_1'); + $output = \Drupal::service('renderer')->renderRoot($rendered_view); + $this->setRawContent($output); + foreach ($expected_values as $expected_entity_values) { + if ($expected_entity_values[$entity_keys['published']] === TRUE && $expected_entity_values['default_revision'] === TRUE) { + $this->assertRaw($expected_entity_values[$entity_keys['label']]); + } + // Node 4 will always appear in the 'stage' workspace because it has + // both an unpublished revision as well as a published one. + elseif ($workspace_id != 'stage' && $expected_entity_values[$entity_keys['id']] != 4) { + $this->assertNoRaw($expected_entity_values[$entity_keys['label']]); + } + } + } + } + + /** + * Asserts that default revisions are properly swapped in a workspace. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityLoad(array $expected_values, $entity_type_id) { + // Filter the expected values so we can check only the default revisions. + $expected_default_revisions = array_filter($expected_values, function ($expected_value) { + return $expected_value['default_revision'] === TRUE; + }); + + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + // Check \Drupal\Core\Entity\EntityStorageInterface::loadMultiple(). + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple(array_column($expected_default_revisions, $id_key)); + foreach ($expected_default_revisions as $expected_default_revision) { + $entity_id = $expected_default_revision[$id_key]; + $this->assertEquals($expected_default_revision[$revision_key], $entities[$entity_id]->getRevisionId()); + $this->assertEquals($expected_default_revision[$label_key], $entities[$entity_id]->label()); + $this->assertEquals($expected_default_revision[$published_key], $entities[$entity_id]->isPublished()); + } + + // Check \Drupal\Core\Entity\EntityStorageInterface::loadUnchanged(). + foreach ($expected_default_revisions as $expected_default_revision) { + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadUnchanged($expected_default_revision[$id_key]); + $this->assertEquals($expected_default_revision[$revision_key], $entity->getRevisionId()); + $this->assertEquals($expected_default_revision[$label_key], $entity->label()); + $this->assertEquals($expected_default_revision[$published_key], $entity->isPublished()); + } + } + + /** + * Asserts that non-default revisions are not changed. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityRevisionLoad(array $expected_values, $entity_type_id) { + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_column($expected_values, $revision_key)); + foreach ($expected_values as $expected_revision) { + $revision_id = $expected_revision[$revision_key]; + $this->assertEquals($expected_revision[$id_key], $entities[$revision_id]->id()); + $this->assertEquals($expected_revision[$revision_key], $entities[$revision_id]->getRevisionId()); + $this->assertEquals($expected_revision[$label_key], $entities[$revision_id]->label()); + $this->assertEquals($expected_revision[$published_key], $entities[$revision_id]->isPublished()); + } + } + + /** + * Asserts that entity queries are giving the correct results in a workspace. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityQuery(array $expected_values, $entity_type_id) { + $storage = $this->entityTypeManager->getStorage($entity_type_id); + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + // Filter the expected values so we can check only the default revisions. + $expected_default_revisions = array_filter($expected_values, function ($expected_value) { + return $expected_value['default_revision'] === TRUE; + }); + + // Check entity query counts. + $result = $storage->getQuery()->count()->execute(); + $this->assertEquals(count($expected_default_revisions), $result); + + $result = $storage->getAggregateQuery()->count()->execute(); + $this->assertEquals(count($expected_default_revisions), $result); + + // Check entity queries with no conditions. + $result = $storage->getQuery()->execute(); + $expected_result = array_combine(array_column($expected_default_revisions, $revision_key), array_column($expected_default_revisions, $id_key)); + $this->assertEquals($expected_result, $result); + + // Check querying each revision individually. + foreach ($expected_values as $expected_value) { + $query = $storage->getQuery(); + $query + ->condition($entity_keys['id'], $expected_value[$id_key]) + ->condition($entity_keys['label'], $expected_value[$label_key]) + ->condition($entity_keys['published'], (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, FALSE); + $tracked_entities = array_filter($tracked_entities, function ($tracked_entity) use ($entity_type_id) { + return $tracked_entity['entity_type_id'] === $entity_type_id; + }); + $this->assertEquals($expected_tracked_revision_ids, array_column($tracked_entities, 'revision_id')); + } + } + + /** + * 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..f2a859e --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php @@ -0,0 +1,42 @@ +setExpectedException(PluginNotFoundException::class, 'The "entity:workspace_association" plugin does not exist.'); + RestResourceConfig::create([ + 'id' => 'entity.workspace_association', + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['GET'], + 'formats' => ['json'], + 'authentication' => ['cookie'], + ], + ]) + ->enable() + ->save(); + } + +} diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml new file mode 100644 index 0000000..fad98f8 --- /dev/null +++ b/core/modules/workspace/workspace.info.yml @@ -0,0 +1,7 @@ +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 diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install new file mode 100644 index 0000000..0be1294 --- /dev/null +++ b/core/modules/workspace/workspace.install @@ -0,0 +1,47 @@ +getStorage('user_role')->getQuery() + ->condition('is_admin', TRUE) + ->execute(); + if (!empty($admin_roles)) { + $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery() + ->condition('roles', $admin_roles, 'IN') + ->condition('status', 1) + ->sort('uid', 'ASC') + ->range(0, 1); + $result = $query->execute(); + } + + // Default to user ID 1 if we could not find any other administrator users. + $owner_id = !empty($result) ? reset($result) : 1; + + // Create two workspaces by default, 'live' and 'stage'. + Workspace::create([ + 'id' => 'live', + 'label' => 'Live', + 'target' => RepositoryHandlerInterface::EMPTY_VALUE, + '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..ca43e86 --- /dev/null +++ b/core/modules/workspace/workspace.module @@ -0,0 +1,260 @@ +' . t('About') . ''; + $output .= '

' . t('The Workspace module allows workspaces to be defined and switched between. Content is then assigned to the active workspace when created. For more information, see the online documentation for the Workspace module.', [':workspace' => 'https://www.drupal.org/node/2824024']) . '

'; + return $output; + } +} + +/** + * Implements hook_entity_type_build(). + */ +function workspace_entity_type_build(array &$entity_types) { + return \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityTypeInfo::class) + ->entityTypeBuild($entity_types); +} + +/** + * Implements hook_form_alter(). + */ +function workspace_form_alter(&$form, FormStateInterface $form_state, $form_id) { + return \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityTypeInfo::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_rest_resource_alter(). + */ +function workspace_rest_resource_alter(&$definitions) { + // WorkspaceAssociation is an internal entity types, therefore it should not + // be exposed via REST. + unset($definitions['entity:workspace_association']); +} + +/** + * 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_renderable_links(), + 'configure' => $configure_link, + ], + '#wrapper_attributes' => [ + 'class' => ['workspace-toolbar-tab'], + ], + '#attached' => [ + 'library' => ['workspace/drupal.workspace.toolbar'], + ], + '#weight' => 500, + ]; + + // Add a special class to the wrapper if we are in the default workspace so we + // can highlight it with a different color. + if ($active_workspace->isDefaultWorkspace()) { + $items['workspace']['#wrapper_attributes']['class'][] = 'is-live'; + } + + return $items; +} + +/** + * Returns an array of workspace activation form links, suitable for rendering. + * + * @return array + * A render array containing links to the workspace activation form. + */ +function workspace_renderable_links() { + $entity_type_manager = \Drupal::entityTypeManager(); + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + $entity_repository = \Drupal::service('entity.repository'); + /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */ + $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(); + + $links = $cache_tags = []; + foreach ($entity_type_manager->getStorage('workspace')->loadMultiple() as $workspace) { + $workspace = $entity_repository->getTranslationFromContext($workspace); + + // Add the 'is-active' class for the currently active workspace. + $options = []; + if ($workspace->id() === $active_workspace->id()) { + $options['attributes']['class'][] = 'is-active'; + } + + // Get the URL of the workspace activation form and display it in a modal. + $url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $workspace->id()], $options); + if ($url->access()) { + $links[$workspace->id()] = [ + 'type' => 'link', + 'title' => $workspace->label(), + 'url' => $url, + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 500, + ]), + ], + ]; + $cache_tags = Cache::mergeTags($cache_tags, $workspace->getCacheTags()); + } + } + + if (!empty($links)) { + $links = [ + '#theme' => 'links__toolbar_workspaces', + '#links' => $links, + '#attributes' => [ + 'class' => ['toolbar-menu'], + ], + '#cache' => [ + 'tags' => $cache_tags, + ], + ]; + } + + return $links; +} diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml new file mode 100644 index 0000000..569957b --- /dev/null +++ b/core/modules/workspace/workspace.permissions.yml @@ -0,0 +1,34 @@ +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 + +update any workspace from its target: + title: Update any workspace from its target + +permission_callbacks: + - Drupal\workspace\EntityAccess::workspacePermissions diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml new file mode 100644 index 0000000..2226501 --- /dev/null +++ b/core/modules/workspace/workspace.routing.yml @@ -0,0 +1,27 @@ +entity.workspace.collection: + path: '/admin/config/workflow/workspace' + defaults: + _title: 'Workspaces' + _entity_list: 'workspace' + requirements: + _permission: 'administer workspaces+edit any workspace' + +entity.workspace.activate_form: + path: '/admin/config/workflow/workspace/{workspace}/activate' + defaults: + _entity_form: 'workspace.activate' + _title: 'Activate Workspace' + options: + _admin_route: TRUE + requirements: + _entity_access: 'workspace.view' + +entity.workspace.deploy_form: + path: '/admin/config/workflow/workspace/{workspace}/deploy' + defaults: + _entity_form: 'workspace.deploy' + _title: 'Deploy Workspace' + options: + _admin_route: TRUE + requirements: + _permission: 'administer workspaces' diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml new file mode 100644 index 0000000..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