diff --git a/core/composer.json b/core/composer.json index c4bb384..6bf4aff 100644 --- a/core/composer.json +++ b/core/composer.json @@ -152,7 +152,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" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 6094bdf..8cd767c 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -1108,7 +1108,9 @@ public function createDuplicate() { $duplicate = clone $this; $entity_type = $this->getEntityType(); - $duplicate->{$entity_type->getKey('id')}->value = NULL; + if ($entity_type->hasKey('id')) { + $duplicate->{$entity_type->getKey('id')}->value = NULL; + } $duplicate->enforceIsNew(); // Check if the entity type supports UUIDs and generate a new one if so. diff --git a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php index c67c2ed..ff8da23 100644 --- a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php @@ -316,13 +316,17 @@ public function fieldAccess($operation, FieldDefinitionInterface $field_definiti $default = $items ? $items->defaultAccess($operation, $account) : AccessResult::allowed(); // Explicitly disallow changing the entity ID and entity UUID. - if ($operation === 'edit') { + $entity = $items ? $items->getEntity() : NULL; + if ($operation === 'edit' && $entity) { if ($field_definition->getName() === $this->entityType->getKey('id')) { - return $return_as_object ? AccessResult::forbidden('The entity ID cannot be changed') : FALSE; + // String IDs can be set when creating the entity. + if (!($entity->isNew() && $field_definition->getType() === 'string')) { + return $return_as_object ? AccessResult::forbidden('The entity ID cannot be changed')->addCacheableDependency($entity) : FALSE; + } } elseif ($field_definition->getName() === $this->entityType->getKey('uuid')) { // UUIDs can be set when creating an entity. - if ($items && ($entity = $items->getEntity()) && !$entity->isNew()) { + if (!$entity->isNew()) { return $return_as_object ? AccessResult::forbidden('The entity UUID cannot be changed')->addCacheableDependency($entity) : FALSE; } } diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php index 0608681..7e9c96b 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/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 4c7947d..48fae2b 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -87,6 +87,14 @@ protected static $patchProtectedFieldNames; /** + * The fields that need a different (random) value for each new entity created + * by a POST request. + * + * @var string[] + */ + protected static $uniqueFieldNames = []; + + /** * Optionally specify which field is the 'label' field. Some entities specify * a 'label_callback', but not a 'label' entity key. For example: User. * @@ -126,6 +134,13 @@ protected $entity; /** + * Another entity of the same type used for testing. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $anotherEntity; + + /** * The entity storage. * * @var \Drupal\Core\Entity\EntityStorageInterface @@ -222,6 +237,22 @@ public function setUp() { abstract protected function createEntity(); /** + * Creates another entity to be tested. + * + * @return \Drupal\Core\Entity\EntityInterface + * Another entity based on $this->entity. + */ + protected function createAnotherEntity() { + $entity = $this->entity->createDuplicate(); + $label_key = $entity->getEntityType()->getKey('label'); + if ($label_key) { + $entity->set($label_key, $entity->label() . '_dupe'); + } + $entity->save(); + return $entity; + } + + /** * Returns the expected normalization of the entity. * * @see ::createEntity() @@ -254,6 +285,46 @@ protected function getNormalizedPatchEntity() { } /** + * Gets the second normalized POST entity. + * + * Entity types can have non-sequential IDs, and in that case the second + * entity created for POST testing needs to be able to specify a different ID. + * + * @see ::testPost + * @see ::getNormalizedPostEntity + * + * @return array + * An array structure as returned by ::getNormalizedPostEntity(). + */ + protected function getSecondNormalizedPostEntity() { + // Return the values of the "parent" method by default. + return $this->getNormalizedPostEntity(); + } + + /** + * Gets the normalized POST entity with random values for its unique fields. + * + * @see ::testPost + * @see ::getNormalizedPostEntity + * + * @return array + * An array structure as returned by ::getNormalizedPostEntity(). + */ + protected function getModifiedEntityForPostTesting() { + $normalized_entity = $this->getNormalizedPostEntity(); + + // Ensure that all the unique fields of the entity type get a new random + // value. + foreach (static::$uniqueFieldNames as $field_name) { + $field_definition = $this->entity->getFieldDefinition($field_name); + $field_type_class = $field_definition->getItemDefinition()->getClass(); + $normalized_entity[$field_name] = $field_type_class::generateSampleValue($field_definition); + } + + return $normalized_entity; + } + + /** * {@inheritdoc} */ protected function getExpectedUnauthorizedAccessMessage($method) { @@ -711,8 +782,8 @@ public function testPost() { // Try with all of the following request bodies. $unparseable_request_body = '!{>}<'; $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format); - $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format); - $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getSecondNormalizedPostEntity(), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity(), 'label'), static::$format); $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format); $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format); @@ -871,8 +942,9 @@ public function testPost() { } $response = $this->request('POST', $url, $request_options); $this->assertResourceResponse(201, FALSE, $response); + $created_entity = $this->entityStorage->load(static::$secondCreatedEntityId); if ($has_canonical_url) { - $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString(); + $location = $created_entity->toUrl('canonical')->setAbsolute(TRUE)->toString(); $this->assertSame([$location], $response->getHeader('Location')); } else { @@ -880,6 +952,32 @@ public function testPost() { } $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) { + // 500 when creating an entity with a duplicate UUID. + $normalized_entity = $this->getModifiedEntityForPostTesting(); + $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $created_entity->uuid()]]; + $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format); + + $response = $this->request('POST', $url, $request_options); + $this->assertSame(500, $response->getStatusCode()); + $this->assertContains('Internal Server Error', (string) $response->getBody()); + + // 201 when successfully creating an entity with a new UUID. + $normalized_entity = $this->getModifiedEntityForPostTesting(); + $new_uuid = \Drupal::service('uuid')->generate(); + $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $new_uuid]]; + $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format); + + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $entities = $this->entityStorage->loadByProperties([$created_entity->getEntityType()->getKey('uuid') => $new_uuid]); + $new_entity = reset($entities); + $this->assertNotNull($new_entity); + $new_entity->delete(); + } + // BC: old default POST URLs have their path updated by the inbound path // processor \Drupal\rest\PathProcessor\PathProcessorEntityResourceBC to the // new URL, which is derived from the 'create' link template if an entity @@ -902,6 +1000,9 @@ public function testPatch() { return; } + // Patch testing requires that another entity of the same type exists. + $this->anotherEntity = $this->createAnotherEntity(); + $this->initAuthentication(); $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); @@ -909,7 +1010,7 @@ public function testPatch() { $unparseable_request_body = '!{>}<'; $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format); $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format); - $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'label'), static::$format); $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format); // The 'field_rest_test' field does not allow 'view' access, so does not end // up in the normalization. Even when we explicitly add it the normalization @@ -1006,6 +1107,18 @@ public function testPatch() { $response = $this->request('PATCH', $url, $request_options); $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response); + // DX: 403 when entity trying to update an entity's ID field. + $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'id'), static::$format);; + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'.", $response); + + if ($this->entity->getEntityType()->hasKey('uuid')) { + // DX: 403 when entity trying to update an entity's UUID field. + $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'uuid'), static::$format);; + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'.", $response); + } + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; // DX: 403 when entity contains field without 'edit' nor 'view' access, even @@ -1308,15 +1421,27 @@ protected static function getModifiedEntityForPatchTesting(EntityInterface $enti * * @param array $normalization * An entity normalization. + * @param string $entity_key + * The entity key whose normalization to make invalid. * * @return array * The updated entity normalization, now invalid. */ - protected function makeNormalizationInvalid(array $normalization) { - // Add a second label to this entity to make it invalid. - $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; - $normalization[$label_field][1]['value'] = 'Second Title'; - + protected function makeNormalizationInvalid(array $normalization, $entity_key) { + $entity_type = $this->entity->getEntityType(); + switch ($entity_key) { + case 'label': + // Add a second label to this entity to make it invalid. + $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName; + $normalization[$label_field][1]['value'] = 'Second Title'; + break; + case 'id': + $normalization[$entity_type->getKey('id')][0]['value'] = $this->anotherEntity->id(); + break; + case 'uuid': + $normalization[$entity_type->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid(); + break; + } return $normalization; } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php index 1e79395..a89bde1 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php @@ -3,10 +3,10 @@ namespace Drupal\Tests\rest\Functional\EntityResource\Feed; use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait; -use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; use Drupal\aggregator\Entity\Feed; -abstract class FeedResourceTestBase extends EntityTestResourceTestBase { +abstract class FeedResourceTestBase extends EntityResourceTestBase { use BcTimestampNormalizerUnixTestTrait; @@ -23,6 +23,16 @@ /** * {@inheritdoc} */ + protected static $patchProtectedFieldNames = []; + + /** + * {@inheritdoc} + */ + protected static $uniqueFieldNames = ['url']; + + /** + * {@inheritdoc} + */ protected function setUpAuthorization($method) { switch ($method) { case 'GET': diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php index f217b97..c7bd57a 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php @@ -81,6 +81,20 @@ protected function createEntity() { /** * {@inheritdoc} */ + protected function createAnotherEntity() { + $entity = $this->entity->createDuplicate(); + $entity->setLink('https://www.example.org/'); + $label_key = $entity->getEntityType()->getKey('label'); + if ($label_key) { + $entity->set($label_key, $entity->label() . '_dupe'); + } + $entity->save(); + return $entity; + } + + /** + * {@inheritdoc} + */ protected function getExpectedNormalizedEntity() { $feed = Feed::load($this->entity->getFeedId()); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php index d25bf2f..7974cba 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php @@ -82,6 +82,17 @@ protected function createEntity() { /** * {@inheritdoc} */ + protected function createAnotherEntity() { + /** @var \Drupal\user\UserInterface $user */ + $user = $this->entity->createDuplicate(); + $user->setUsername($user->label() . '_dupe'); + $user->save(); + return $user; + } + + /** + * {@inheritdoc} + */ protected function getExpectedNormalizedEntity() { return [ 'uid' => [ @@ -244,6 +255,49 @@ protected function assertRpcLogin($username, $password) { } /** + * Tests PATCHing security-sensitive base fields to change other users. + */ + public function testPatchSecurityOtherUser() { + // The anonymous user is never allowed to modify other users. + if (!static::$auth) { + $this->markTestSkipped(); + } + + $this->initAuthentication(); + $this->provisionEntityResource(); + + /** @var \Drupal\user\UserInterface $user */ + $user = $this->account; + $original_normalization = array_diff_key($this->serializer->normalize($user, static::$format), ['changed' => TRUE]); + + // Since this test must be performed by the user that is being modified, + // we cannot use $this->getUrl(). + $url = $user->toUrl()->setOption('query', ['_format' => static::$format]); + $request_options = [ + RequestOptions::HEADERS => ['Content-Type' => static::$mimeType], + ]; + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH')); + + $normalization = $original_normalization; + $normalization['mail'] = [['value' => 'new-email@example.com']]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + // Try changing user 1's email. + $user1 = [ + 'mail' => [['value' => 'another_email_address@example.com']], + 'uid' => [['value' => 1]], + 'name' => [['value' => 'another_user_name']], + 'pass' => [['existing' => $this->account->passRaw]], + 'uuid' => [['value' => '2e9403a4-d8af-4096-a116-624710140be0']], + ] + $original_normalization; + $request_options[RequestOptions::BODY] = $this->serializer->encode($user1, static::$format); + $response = $this->request('PATCH', $url, $request_options); + // Ensure the email address has not changed. + $this->assertEquals('admin@example.com', $this->entityStorage->loadUnchanged(1)->getEmail()); + $this->assertResourceErrorResponse(403, "Access denied on updating field 'uid'.", $response); + } + + /** * {@inheritdoc} */ protected function getExpectedUnauthorizedAccessMessage($method) { diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php index dbf74b1..2281d33 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php @@ -41,4 +41,12 @@ public function testPatchDxForSecuritySensitiveBaseFields() { $this->markTestSkipped(); } + /** + * {@inheritdoc} + */ + public function testPatchSecurityOtherUser() { + // Deserialization of the XML format is not supported. + $this->markTestSkipped(); + } + } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php index d1ef16e..3e01ce0 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php @@ -36,4 +36,12 @@ public function testPatchDxForSecuritySensitiveBaseFields() { $this->markTestSkipped(); } + /** + * {@inheritdoc} + */ + public function testPatchSecurityOtherUser() { + // Deserialization of the XML format is not supported. + $this->markTestSkipped(); + } + } 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..b5e21ce --- /dev/null +++ b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml @@ -0,0 +1,15 @@ +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 + upstream: 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..516e690 --- /dev/null +++ b/core/modules/workspace/src/Annotation/RepositoryHandler.php @@ -0,0 +1,60 @@ +get('history')->getValue(); + } + + /** + * {@inheritdoc} + */ + public function appendHistory(array $history) { + // We need to wrap the passed-in argument in another array in order for it + // to be set as the first item (i.e. delta = 0) of the field item list. + $histories = array_merge([$history], $this->getHistory()); + $this->set('history', $histories); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getSessionId() { + return $this->get('session_id')->value; + } + + /** + * {@inheritdoc} + */ + public function setSessionId($session_id) { + $this->set('session_id', $session_id); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getSourceLastSequence() { + return $this->get('source_last_sequence')->value; + } + + /** + * {@inheritdoc} + */ + public function setSourceLastSequence($source_last_sequence) { + $this->set('source_last_sequence', $source_last_sequence); + return $this; + } + + /** + * {@inheritdoc} + */ + public static function loadOrCreate($id) { + $entity = static::load($id); + if (!$entity) { + $entity = static::create(['id' => $id]); + } + + return $entity; + } + + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + + $fields['id'] = BaseFieldDefinition::create('string') + ->setLabel(new TranslatableMarkup('Replication log ID')) + ->setDescription(new TranslatableMarkup('The replication log ID.')) + ->setReadOnly(TRUE) + ->setRequired(TRUE) + ->setSetting('max_length', 128) + ->setSetting('is_ascii', TRUE); + + $fields['history'] = BaseFieldDefinition::create('replication_history') + ->setLabel(new TranslatableMarkup('Replication log history')) + ->setDescription(new TranslatableMarkup('The version id of the test entity.')) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + + $fields['session_id'] = BaseFieldDefinition::create('uuid') + ->setLabel(new TranslatableMarkup('Replication session ID')) + ->setDescription(new TranslatableMarkup('The unique session ID of the last replication. Shortcut to the session_id in the last history item.')); + + $fields['source_last_sequence'] = BaseFieldDefinition::create('string') + ->setLabel(new TranslatableMarkup('Last processed checkpoint')) + ->setDescription(new TranslatableMarkup('The last processed checkpoint. Shortcut to the source_last_sequence in the last history item.')); + + $fields['status'] = BaseFieldDefinition::create('boolean') + ->setLabel(new TranslatableMarkup('Status')) + ->setDescription(new TranslatableMarkup('Replication status')) + ->setDefaultValue(TRUE); + + return $fields; + } + +} diff --git a/core/modules/workspace/src/Entity/Workspace.php b/core/modules/workspace/src/Entity/Workspace.php new file mode 100644 index 0000000..f8bac92 --- /dev/null +++ b/core/modules/workspace/src/Entity/Workspace.php @@ -0,0 +1,206 @@ +setLabel(new TranslatableMarkup('Workspace ID')) + ->setDescription(new TranslatableMarkup('The workspace ID.')) + ->setSetting('max_length', 128) + ->setRequired(TRUE) + ->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['upstream'] = BaseFieldDefinition::create('string') + ->setLabel(new TranslatableMarkup('Target workspace')) + ->setDescription(new TranslatableMarkup('The workspace to push to and pull from.')) + ->setRevisionable(TRUE) + ->setRequired(TRUE) + ->setDisplayOptions('form', [ + 'type' => 'workspace_upstream', + 'weight' => 4, + ]) + ->setDisplayConfigurable('form', TRUE) + ->addPropertyConstraints('value', [ + 'Upstream' => [], + ]); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function getRepositoryHandlerPlugin() { + if (($upstream = $this->upstream->value) && $upstream !== RepositoryHandlerInterface::EMPTY_VALUE) { + return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance($upstream); + } + } + + /** + * {@inheritdoc} + */ + public function getLocalRepositoryHandlerPlugin() { + return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance('local_workspace:' . $this->id()); + } + + /** + * {@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; + } + + /** + * 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..db38755 --- /dev/null +++ b/core/modules/workspace/src/Entity/WorkspaceAssociation.php @@ -0,0 +1,73 @@ +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 this workspace is for.')) + ->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 this workspace is for.')) + ->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 this workspace is for.')) + ->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..8e2f561 --- /dev/null +++ b/core/modules/workspace/src/EntityAccess.php @@ -0,0 +1,174 @@ +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()) + ->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. + * + * Note: This approach assumes that a site will have only a small number of + * workspace entities, under a dozen. If there are many dozens of workspaces + * defined then this approach will have scaling issues. + * + * @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..9f80b1c --- /dev/null +++ b/core/modules/workspace/src/EntityOperations.php @@ -0,0 +1,251 @@ +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; + } + + // Handle the case when a new published entity was created in a non-default + // workspace and create a published pending revision for it. + if (isset($entity->_initialPublished)) { + // Operate on a clone to avoid changing the entity prior to subsequent + // hook_entity_insert() implementations. + $pending_revision = clone $entity; + $pending_revision->setPublished(); + $pending_revision->isDefaultRevision(FALSE); + $pending_revision->save(); + } + else { + $this->trackEntity($entity); + } + } + + /** + * 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; + } + + $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/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/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/SqlQueryFactory.php b/core/modules/workspace/src/EntityQuery/SqlQueryFactory.php new file mode 100644 index 0000000..39bef05 --- /dev/null +++ b/core/modules/workspace/src/EntityQuery/SqlQueryFactory.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/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/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php new file mode 100644 index 0000000..93697c6 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php @@ -0,0 +1,108 @@ +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 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/WorkspaceDeployForm.php b/core/modules/workspace/src/Form/WorkspaceDeployForm.php new file mode 100644 index 0000000..d17a99d --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceDeployForm.php @@ -0,0 +1,152 @@ +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); + + /* @var \Drupal\workspace\WorkspaceInterface $workspace */ + $workspace = $this->entity; + + // We can not deploy if we do not have an upstream workspace. + if (!$workspace->getRepositoryHandlerPlugin()) { + throw new HttpException(500, 'The specified repository handler plugin does not exist.'); + } + + $form['help'] = [ + '#markup' => $this->t('Deploy all %source_upstream_label content to %target_upstream_label, or refresh %source_upstream_label with content from %target_upstream_label.', ['%source_upstream_label' => $workspace->getLocalRepositoryHandlerPlugin()->getLabel(), '%target_upstream_label' => $workspace->getRepositoryHandlerPlugin()->getLabel()]), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function actions(array $form, FormStateInterface $form_state) { + $elements = parent::actions($form, $form_state); + + $upstream_label = $this->entity->getRepositoryHandlerPlugin()->getLabel(); + + $elements['submit']['#value'] = $this->t('Deploy to @upstream', ['@upstream' => $upstream_label]); + $elements['submit']['#submit'] = ['::submitForm', '::deploy']; + $elements['update'] = [ + '#type' => 'submit', + '#value' => $this->t('Refresh from @upstream', ['@upstream' => $upstream_label]), + '#submit' => ['::submitForm', '::update'], + ]; + $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 upstream 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 deploy(array &$form, FormStateInterface $form_state) { + $workspace = $this->entity; + + try { + $repository_handler = $workspace->getRepositoryHandlerPlugin(); + $repository_handler->replicate($workspace->getLocalRepositoryHandlerPlugin(), $repository_handler); + $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 upstream 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 update(array &$form, FormStateInterface $form_state) { + $workspace = $this->entity; + + try { + $repository_handler = $workspace->getRepositoryHandlerPlugin(); + $repository_handler->replicate($repository_handler, $workspace->getLocalRepositoryHandlerPlugin()); + $this->messenger->addMessage($this->t('Update successful.')); + } + catch (\Exception $e) { + watchdog_exception('workspace', $e); + $this->messenger->addMessage($this->t('Update 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..48eb528 --- /dev/null +++ b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php @@ -0,0 +1,85 @@ +currentUser = $current_user; + $this->tempstore = $tempstore_factory->get('workspace.negotiator.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->tempstore->get('active_workspace_id'); + + if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) { + return $workspace; + } + + return NULL; + } + + /** + * {@inheritdoc} + */ + public function setActiveWorkspace(WorkspaceInterface $workspace) { + $this->tempstore->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/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php b/core/modules/workspace/src/Plugin/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php new file mode 100644 index 0000000..fc147d2 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php @@ -0,0 +1,56 @@ +workspaceStorage = $workspace_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager')->getStorage('workspace') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + + // Provide a local repository handler plugin for each workspace. + foreach ($this->workspaceStorage->loadMultiple() as $workspace_id => $workspace) { + $this->derivatives[$workspace_id] = $base_plugin_definition; + $this->derivatives[$workspace_id]['label'] = $workspace->label(); + $this->derivatives[$workspace_id]['category'] = $base_plugin_definition['label']; + } + return $this->derivatives; + } + +} diff --git a/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php new file mode 100644 index 0000000..8bc1dc0 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php @@ -0,0 +1,145 @@ +setLabel(new TranslatableMarkup('Write failures')) + ->setDescription(new TranslatableMarkup('Number of failed entity writes')) + ->setRequired(FALSE); + + $properties['entities_read'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('Entities read')) + ->setDescription(new TranslatableMarkup('Number of entities read.')) + ->setRequired(FALSE); + + $properties['entities_written'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('Entities written')) + ->setDescription(new TranslatableMarkup('Number of entities written.')) + ->setRequired(FALSE); + + $properties['end_last_sequence'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('End sequence')) + ->setDescription(new TranslatableMarkup('Sequence ID where the replication ended.')) + ->setRequired(FALSE); + + $properties['end_time'] = DataDefinition::create('datetime_iso8601') + ->setLabel(new TranslatableMarkup('End time')) + ->setDescription(new TranslatableMarkup('Date and time when replication ended.')) + ->setRequired(FALSE); + + $properties['recorded_sequence'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('Recorded sequence')) + ->setDescription(new TranslatableMarkup('Recorded intermediate sequence.')) + ->setRequired(FALSE); + + $properties['session_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Session ID')) + ->setDescription(new TranslatableMarkup('Unique session ID for the replication.')) + ->setRequired(TRUE); + + $properties['start_last_sequence'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('Start sequence')) + ->setDescription(new TranslatableMarkup('Sequence ID where the replication started.')) + ->setRequired(FALSE); + + $properties['start_time'] = DataDefinition::create('datetime_iso8601') + ->setLabel(new TranslatableMarkup('Start time')) + ->setDescription(new TranslatableMarkup('Date and time when replication started.')) + ->setRequired(FALSE); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + return [ + 'columns' => [ + 'entity_write_failures' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ], + 'entities_read' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ], + 'entities_written' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ], + 'end_last_sequence' => [ + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + ], + 'end_time' => [ + 'type' => 'varchar', + 'length' => 50, + 'not null' => FALSE, + ], + 'recorded_sequence' => [ + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + 'default' => 0, + ], + 'session_id' => [ + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + ], + 'start_last_sequence' => [ + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + ], + 'start_time' => [ + 'type' => 'varchar', + 'length' => 50, + 'not null' => FALSE, + ], + ], + ]; + } + +} diff --git a/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php b/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php new file mode 100644 index 0000000..3c2dae6 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php @@ -0,0 +1,133 @@ +upstreamPluginManager = $upstream_plugin_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('plugin.manager.workspace.repository_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + /** @var \Drupal\workspace\WorkspaceInterface $workspace */ + $workspace = $items->getEntity(); + + // Gather the list of upstreams grouped by category. + $upstream_options = []; + foreach ($this->upstreamPluginManager->getGroupedDefinitions() as $category => $upstream_plugin_definitions) { + foreach ($upstream_plugin_definitions as $plugin_id => $plugin_definition) { + // Do not include the local workspace itself as an option. + if ($plugin_id !== 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . $workspace->id()) { + $upstream_options[$category][$plugin_id] = $plugin_definition['label']; + } + } + } + + // The default ('Live') workspace can not have another local workspace as an + // upstream value, so we need to remove all options from the + // 'Local workspace' category. + if ($workspace->isDefaultWorkspace()) { + unset($upstream_options['Local workspace']); + } + + // In case we don't have any options to display, just provide the existing + // value. This can happen for example when editing the 'Live' workspace and + // the only available repository handler plugin is 'local_workspace'. + if (!$upstream_options) { + $element += [ + '#type' => 'value', + '#value' => isset($items[$delta]->value) ? $items[$delta]->value : RepositoryHandlerInterface::EMPTY_VALUE, + ]; + } + else { + $element += [ + '#type' => 'select', + '#default_value' => isset($items[$delta]->value) ? $items[$delta]->value : 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . WorkspaceManager::DEFAULT_WORKSPACE, + '#options' => $upstream_options, + ]; + + // Simplify the form and use radio buttons if we only have one category to + // display. + if (count($upstream_options) == 1) { + $element['#type'] = 'radios'; + $element['#options'] = reset($upstream_options); + } + } + + return ['value' => $element]; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + // We only want to have this widget available for the 'upstream' base field + // from the Workspace entity type. + return $field_definition->getTargetEntityTypeId() === 'workspace'; + } + +} diff --git a/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php b/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php new file mode 100644 index 0000000..d06e4f2 --- /dev/null +++ b/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php @@ -0,0 +1,269 @@ +entityTypeManager = $entity_type_manager; + $this->workspaceManager = $workspace_manager; + $this->database = $database; + $this->uuidService = $uuid_service; + $this->upstreamWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->getDerivativeId()); + } + + /** + * {@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('workspace.manager'), + $container->get('database'), + $container->get('uuid') + ); + } + + /** + * {@inheritdoc} + */ + public function getLabel() { + return $this->upstreamWorkspace->label(); + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + $this->dependencies = parent::calculateDependencies(); + $this->addDependency($this->upstreamWorkspace->getConfigDependencyKey(), $this->upstreamWorkspace->getConfigDependencyName()); + + return $this->dependencies; + } + + /** + * {@inheritdoc} + */ + public function replicate(RepositoryHandlerInterface $source, RepositoryHandlerInterface $target) { + // Replicating content from one workspace to another on the same site + // roughly follows the CouchDB replication protocol. + // @see http://docs.couchdb.org/en/2.1.0/replication/protocol.html + /** @var \Drupal\workspace\WorkspaceInterface $source_workspace */ + $source_workspace = $this->entityTypeManager->getStorage('workspace')->load($source->getDerivativeId()); + /** @var \Drupal\workspace\WorkspaceInterface $target_workspace */ + $target_workspace = $this->entityTypeManager->getStorage('workspace')->load($target->getDerivativeId()); + + $start_time = new \DateTime(); + // @todo Figure out if we want to include more information in the + // replication log ID. + // @see http://docs.couchdb.org/en/2.0.0/replication/protocol.html#generate-replication-id + $replication_id = hash('sha256', $source_workspace->id() . $target_workspace->id()); + + // Load or create the Replication Log entity based on the replication ID. + $replication_log = ReplicationLog::loadOrCreate($replication_id); + + // Get the current active workspace, so we can set it back as the active + // after the replication has completed. + $current_active = $this->workspaceManager->getActiveWorkspace(); + + // Set the source as the active workspace, so we can fetch all the entities + // relative to the source workspace. + $this->workspaceManager->setActiveWorkspace($source_workspace); + + $workspace_association_ids = []; + foreach ($this->workspaceManager->getSupportedEntityTypes() as $entity_type_id => $entity_type) { + // Get all entity revision IDs for all entities which are in only one + // of either the source or the target workspaces. We assume that this + // means the revision is in the source, but not the target, and the + // revision has not been replicated yet. + $select = $this->database + ->select('workspace_association_revision', 'war') + ->fields('war', ['content_entity_revision_id']); + $select->condition('content_entity_type_id', $entity_type_id); + $select->condition('workspace', [$source_workspace->id(), $target_workspace->id()], 'IN'); + $select->groupBy('content_entity_revision_id'); + $select->having('count(workspace) < :workspaces', [':workspaces' => 2]); + $revision_difference = $select->execute()->fetchCol(); + + if (!empty($revision_difference)) { + // Get the workspace association IDs for all of the entity revision IDs + // which are not yet in the target workspace. + $workspace_association_ids[$entity_type_id] = $this->entityTypeManager + ->getStorage('workspace_association') + ->getQuery() + ->allRevisions() + ->condition('content_entity_type_id', $entity_type_id) + ->condition('content_entity_revision_id', $revision_difference, 'IN') + ->condition('workspace', $source_workspace->id()) + ->execute(); + } + } + + $entities = []; + foreach ($workspace_association_ids as $entity_type_id => $ids) { + foreach ($ids as $revision_id => $entity_id) { + // Get the workspace association entity for revision that is in the source + // workspace. + /** @var \Drupal\Core\Entity\ContentEntityInterface $workspace_association */ + $workspace_association = $this->entityTypeManager->getStorage('workspace_association')->loadRevision($revision_id); + if ($target_workspace->isDefaultWorkspace()) { + // If the target workspace is the default workspace, the revision + // needs to be set to the default revision. + /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity */ + $entity = $this->entityTypeManager + ->getStorage($workspace_association->content_entity_type_id->value) + ->loadRevision($workspace_association->content_entity_revision_id->value); + $entity->_isReplicating = TRUE; + $entity->isDefaultRevision(TRUE); + $entities[] = $entity; + } + else { + // If the target workspace is not the default workspace, the content + // workspace link entity can simply be updated with the target + // workspace. + $workspace_association->setNewRevision(TRUE); + $workspace_association->workspace->target_id = $target_workspace->id(); + $workspace_association->save(); + } + } + } + + // Only switch to the target workspace and save entities if there are some + // to save. + if (!empty($entities)) { + // Before saving set the active workspace to the target. + $this->workspaceManager->setActiveWorkspace($target_workspace); + // Save each revision on the target workspace. + foreach ($entities as $entity) { + $entity->save(); + } + } + + // Switch back to the original active workspace, so that the user performing + // the replication is back on the workspace they started on. + $this->workspaceManager->setActiveWorkspace($current_active); + + // Update the replication log entity by adding the completed replication to + // the history. + return $this->updateReplicationLog($replication_log, [ + 'entity_write_failures' => 0, + 'end_time' => (new \DateTime())->format('D, d M Y H:i:s e'), + 'session_id' => $this->uuidService->generate(), + 'start_last_sequence' => $this->getLastSequenceId($replication_log), + 'start_time' => $start_time->format('D, d M Y H:i:s e'), + ]); + } + + /** + * Gets the last sequence ID from a replication log entity. + * + * @param \Drupal\workspace\ReplicationLogInterface $replication_log + * The replication log entity to get the last sequence ID from. + * + * @return int + * The last sequence ID for the replication log. + */ + protected function getLastSequenceId(ReplicationLogInterface $replication_log) { + $history = $replication_log->getHistory(); + return isset($history[0]['recorded_sequence']) ? $history[0]['recorded_sequence'] : 0; + } + + /** + * Updates the replication log entity with the given history. + * + * @param \Drupal\workspace\ReplicationLogInterface $replication_log + * The replication log entity to be updated. + * @param array $history + * The new history items. + * + * @return \Drupal\workspace\ReplicationLogInterface + * The updated replication log entity. + */ + protected function updateReplicationLog(ReplicationLogInterface $replication_log, array $history) { + $replication_log->appendHistory($history); + $replication_log->setSessionId($history['session_id']); + $replication_log->save(); + return $replication_log; + } + +} diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php b/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php new file mode 100644 index 0000000..84f0a91 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php @@ -0,0 +1,24 @@ +getTypedData()->getParent(); + $entity = $field_item->getEntity(); + + if (!$entity->isNew() && $value === 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . $entity->id()) { + $this->context->addViolation($constraint->message); + } + } + +} diff --git a/core/modules/workspace/src/ReplicationLogInterface.php b/core/modules/workspace/src/ReplicationLogInterface.php new file mode 100644 index 0000000..ebedeca --- /dev/null +++ b/core/modules/workspace/src/ReplicationLogInterface.php @@ -0,0 +1,83 @@ +getPluginDefinition()['label']; + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->getPluginDefinition()['description']; + } + + /** + * {@inheritdoc} + */ + public function isRemote() { + return $this->getPluginDefinition()['remote']; + } + + /** + * {@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..49f3dea --- /dev/null +++ b/core/modules/workspace/src/RepositoryHandlerInterface.php @@ -0,0 +1,65 @@ +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..a779d90 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceAccessControlHandler.php @@ -0,0 +1,54 @@ +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 @@ +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/WorkspaceInterface.php b/core/modules/workspace/src/WorkspaceInterface.php new file mode 100644 index 0000000..91c140d --- /dev/null +++ b/core/modules/workspace/src/WorkspaceInterface.php @@ -0,0 +1,58 @@ +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..68dbf32 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManager.php @@ -0,0 +1,194 @@ +requestStack = $request_stack; + $this->entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + $this->logger = $logger; + $this->classResolver = $class_resolver; + $this->negotiatorIds = $negotiator_ids; + } + + /** + * {@inheritdoc} + */ + public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type) { + if (!in_array($entity_type->id(), $this->blacklist, TRUE) + && $entity_type->entityClassImplements(EntityPublishedInterface::class) + && $entity_type->isRevisionable()) { + return TRUE; + } + $this->blacklist[] = $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() { + $request = $this->requestStack->getCurrentRequest(); + foreach ($this->negotiatorIds as $negotiator_id) { + $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); + if ($negotiator->applies($request)) { + if ($active_workspace = $negotiator->getActiveWorkspace($request)) { + break; + } + } + } + + // The default workspace negotiator always returns a valid workspace. + return $active_workspace; + } + + /** + * {@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}', ['workspace' => $workspace->label()]); + throw new WorkspaceAccessException('The user does not have permission to view that 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; + } + +} 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); + + // Switch core's SQL entity query factory to our own so we can reliably + // alter entity queries. + // @todo Do the same for the pgsql entity query backend override. + $definition = $container->getDefinition('entity.query.sql'); + $definition->setClass('Drupal\workspace\EntityQuery\SqlQueryFactory'); + $definition->addArgument($container->getDefinition('workspace.manager')); + } + +} 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', + 'upstream' => 'local_workspace: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(), + ], + ], + 'upstream' => [ + [ + 'value' => 'local_workspace:live', + ], + ], + 'uuid' => [ + [ + 'value' => $this->entity->uuid() + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'id' => [ + [ + 'value' => static::$firstCreatedEntityId, + ], + ], + 'label' => [ + [ + 'value' => 'Running on faith', + ], + ], + 'upstream' => [ + [ + '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', + ], + ], + 'upstream' => [ + [ + '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); + } + + /** + * {@inheritdoc} + */ + public function testDelete() { + // @todo Workspaces can not yet be deleted. + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php similarity index 54% copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php index d1ef16e..5004f53 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php +++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php @@ -1,16 +1,18 @@ markTestSkipped(); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php similarity index 72% copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php index dbf74b1..ac6f3c5 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php +++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php @@ -1,14 +1,16 @@ markTestSkipped(); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php similarity index 71% copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php index d1ef16e..c8a23d1 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php +++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php @@ -1,14 +1,16 @@ 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..04bd463 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php @@ -0,0 +1,124 @@ +createNodeType('Test', '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->createNodeType('Test', '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..2db3006 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php @@ -0,0 +1,85 @@ +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); + + $this->setupWorkspaceSwitcherBlock(); + $stage = Workspace::load('stage'); + $this->switchToWorkspace($stage); + + $cache_context = new WorkspaceCacheContext(\Drupal::service('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/WorkspacePermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php new file mode 100644 index 0000000..ba61faf --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php @@ -0,0 +1,142 @@ +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); + + // @todo add Deletion checks once there's a UI for deletion. + } + + /** + * 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(t('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(t('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); + } + +} 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..78c6f8d --- /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(t('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..097886f --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php @@ -0,0 +1,109 @@ +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(t('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', + 'upstream[0][value]' => 'local_workspace:live', + ], '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..328a51b --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php @@ -0,0 +1,187 @@ +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. + * @param string $upstream + * The ID of the replication handler plugin of the workspace. + * + * @return \Drupal\Core\Entity\EntityInterface + * The workspace that was just created. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function createWorkspaceThroughUi($label, $id, $upstream = 'local_workspace:live') { + $this->drupalPostForm('/admin/config/workflow/workspace/add', [ + 'id' => $id, + 'label' => $label, + 'upstream[0][value]' => $upstream, + ], '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->drupalPlaceBlock('workspace_switcher_block', [ + '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\workspace\WorkspaceManager $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + if ($workspace_manager->getActiveWorkspace()->id() !== $workspace->id()) { + // Switch the system under test to the specified workspace. + /** @var \Drupal\Tests\WebAssert $session */ + $session = $this->assertSession(); + $session->buttonExists('Activate'); + $this->drupalPostForm(NULL, ['workspace_id' => $workspace->id()], t('Activate')); + $session->pageTextContains($workspace->label() . ' is now the active workspace.'); + + // Switch the test runner's context to the specified workspace. + \Drupal::service('workspace.manager')->setActiveWorkspace($workspace); + + // If we don't do both of those, test runner utility methods will not be + // run in the same workspace as the system under test, and you'll be left + // wondering why your test runner cannot find content you just created. + } + } + + /** + * Creates a new node type. + * + * @param string $label + * The human-readable label of the type to create. + * @param string $machine_name + * The machine name of the type to create. + */ + protected function createNodeType($label, $machine_name) { + $node_type = NodeType::create([ + 'type' => $machine_name, + 'label' => $label, + ]); + $node_type->save(); + } + + /** + * Creates a node by "clicking" buttons. + * + * @param string $label + * The label of the Node to create. + * @param string $bundle + * The bundle of the Node to create. + * @param bool $publish + * The publishing status to set. + * + * @return \Drupal\Core\Entity\EntityInterface + * The Node that was just created. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function createNodeThroughUi($label, $bundle, $publish = TRUE) { + $this->drupalGet('/node/add/' . $bundle); + + /** @var \Behat\Mink\Session $session */ + $session = $this->getSession(); + $this->assertSession()->statusCodeEquals(200); + + /** @var \Behat\Mink\Element\DocumentElement $page */ + $page = $session->getPage(); + $page->fillField('Title', $label); + if ($publish) { + $page->findButton(t('Save'))->click(); + } + else { + $page->uncheckField('Published'); + $page->findButton(t('Save'))->click(); + } + + $session->getPage()->hasContent("{$label} has been created"); + + return $this->getOneEntityByLabel('node', $label); + } + + /** + * Determine if the content list has an entity's label. + * + * This assertion can be used to validate a particular entity exists in the + * current workspace. + */ + protected function isLabelInContentOverview($label) { + $this->drupalGet('/admin/content'); + $session = $this->getSession(); + $this->assertSession()->statusCodeEquals(200); + $page = $session->getPage(); + return $page->hasContent($label); + } + +} diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php new file mode 100644 index 0000000..a0ed21b --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php @@ -0,0 +1,41 @@ +drupalLogin($this->rootUser); + $this->drupalGet('/admin/modules/uninstall'); + $session = $this->assertSession(); + $session->linkExists('Remove workspaces'); + $this->clickLink('Remove workspaces'); + $session->pageTextContains('Are you sure you want to delete all workspaces?'); + $this->drupalPostForm('/admin/modules/uninstall/entity/workspace', [], 'Delete all workspaces'); + $this->drupalPostForm('admin/modules/uninstall', ['uninstall[workspace]' => TRUE], 'Uninstall'); + $this->drupalPostForm(NULL, [], 'Uninstall'); + $session->pageTextContains('The selected modules have been uninstalled.'); + $session->pageTextNotContains('Workspace'); + } + +} diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php new file mode 100644 index 0000000..27ca922 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php @@ -0,0 +1,98 @@ +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..76242ef --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php @@ -0,0 +1,78 @@ +installSchema('system', ['sequences']); + + $this->installEntitySchema('workspace'); + $this->installEntitySchema('user'); + + // User 1. + $this->createUser(); + } + + /** + * Test cases for testWorkspaceAccess(). + * + * @return array + * An array of operations and permissions to test with. + */ + public function operationCases() { + return [ + ['create', 'create workspace'], + ['view', 'view any workspace'], + ['view', 'view own workspace'], + ['update', 'edit any workspace'], + ['update', 'edit own workspace'], + ]; + } + + /** + * Verifies all workspace roles have the correct access for the operation. + * + * @param string $operation + * The operation to test with. + * @param string $permission + * The permission to test with. + * + * @dataProvider operationCases + */ + public function testWorkspaceAccess($operation, $permission) { + $user = $this->createUser(); + $this->setCurrentUser($user); + $workspace = Workspace::create(['id' => 'oak']); + $workspace->save(); + $role = $this->createRole([$permission]); + $user->addRole($role); + $this->assertTrue($workspace->access($operation, $user)); + } + +} diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php new file mode 100644 index 0000000..4babd2c --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php @@ -0,0 +1,633 @@ +entityTypeManager = \Drupal::entityTypeManager(); + + $this->installConfig(['filter', 'node', 'system']); + + $this->installSchema('system', ['key_value_expire', 'sequences']); + $this->installSchema('node', ['node_access']); + + $this->installEntitySchema('entity_test_mulrev'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + + $this->createContentType(['type' => 'page']); + + $this->setCurrentUser($this->createUser(['administer nodes'])); + + // Create two nodes, a published and an unpublished one, so we can test the + // behavior of the module with default/existing content. + $this->createdTimestamp = \Drupal::time()->getRequestTime(); + $this->createNode(['title' => 'live - 1 - r1 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]); + $this->createNode(['title' => 'live - 2 - r2 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]); + } + + /** + * Enables the Workspace module and creates two workspaces. + */ + protected function initializeWorkspaceModule() { + // Enable the Workspace module here instead of the static::$modules array so + // we can test it with default content. + $this->enableModules(['workspace']); + $this->container = \Drupal::getContainer(); + $this->entityTypeManager = \Drupal::entityTypeManager(); + + $this->installEntitySchema('workspace'); + $this->installEntitySchema('workspace_association'); + $this->installEntitySchema('replication_log'); + + // Create two workspaces by default, 'live' and 'stage'. + $this->workspaces['live'] = Workspace::create(['id' => 'live']); + $this->workspaces['live']->save(); + $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'upstream' => 'local_workspace:live']); + $this->workspaces['stage']->save(); + + $permissions = [ + 'administer nodes', + 'create workspace', + 'edit any workspace', + 'view any workspace', + ]; + $this->setCurrentUser($this->createUser($permissions)); + } + + /** + * Tests various scenarios for creating and deploying content in workspaces. + */ + public function testWorkspaces() { + $this->initializeWorkspaceModule(); + + // Notes about the structure of the test scenarios: + // - 'default_revision' indicates the entity revision that should be + // returned by entity_load(), non-revision entity queries and non-revision + // views *in a given workspace*, it does not indicate what is actually + // stored in the base entity tables. + $test_scenarios = []; + + // A multi-dimensional array keyed by the workspace ID, then by the entity + // and finally by the revision ID. + // + // In the initial state we have only the two revisions that were created + // before the Workspace module was installed. + $revision_state = [ + 'live' => [ + 1 => [ + 1 => [ + 'title' => 'live - 1 - r1 - published', + 'status' => TRUE, + 'default_revision' => TRUE, + ], + ], + 2 => [ + 2 => [ + 'title' => 'live - 2 - r2 - unpublished', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + ], + ], + 'stage' => [ + 1 => [ + 1 => [ + 'title' => 'live - 1 - r1 - published', + 'status' => TRUE, + 'default_revision' => TRUE, + ], + ], + 2 => [ + 2 => [ + 'title' => 'live - 2 - r2 - unpublished', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + ], + ], + ]; + $test_scenarios['initial_state'] = $revision_state; + + // Unpublish node 1 in 'stage'. The new revision is also added to 'live' but + // it is not the default revision. + $revision_state = array_replace_recursive($revision_state, [ + 'live' => [ + 1 => [ + 3 => [ + 'title' => 'stage - 1 - r3 - unpublished', + 'status' => FALSE, + 'default_revision' => FALSE, + ], + ], + ], + 'stage' => [ + 1 => [ + 1 => ['default_revision' => FALSE], + 3 => [ + 'title' => 'stage - 1 - r3 - unpublished', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + ], + ], + ]); + $test_scenarios['unpublish_node_1_in_stage'] = $revision_state; + + // Publish node 2 in 'stage'. The new revision is also added to 'live' but + // it is not the default revision. + $revision_state = array_replace_recursive($revision_state, [ + 'live' => [ + 2 => [ + 4 => [ + 'title' => 'stage - 2 - r4 - published', + 'status' => TRUE, + 'default_revision' => FALSE, + ], + ], + ], + 'stage' => [ + 2 => [ + 2 => ['default_revision' => FALSE], + 4 => [ + 'title' => 'stage - 2 - r4 - published', + 'status' => TRUE, + 'default_revision' => TRUE, + ], + ], + ], + ]); + $test_scenarios['publish_node_2_in_stage'] = $revision_state; + + // Adding a new unpublished node on 'stage' should create a single + // unpublished revision on both 'stage' and 'live'. + $revision_state = array_replace_recursive($revision_state, [ + 'live' => [ + 3 => [ + 5 => [ + 'title' => 'stage - 3 - r5 - unpublished', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + ], + ], + 'stage' => [ + 3 => [ + 5 => [ + 'title' => 'stage - 3 - r5 - unpublished', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + ], + ], + ]); + $test_scenarios['add_unpublished_node_in_stage'] = $revision_state; + + // Adding a new published node on 'stage' should create two revisions, an + // unpublished revision on 'live' and a published one on 'stage'. + $revision_state = array_replace_recursive($revision_state, [ + 'live' => [ + 4 => [ + 6 => [ + 'title' => 'stage - 4 - r6 - published', + 'status' => FALSE, + 'default_revision' => TRUE, + ], + 7 => [ + 'title' => 'stage - 4 - r6 - published', + 'status' => TRUE, + 'default_revision' => FALSE, + ], + ], + ], + 'stage' => [ + 4 => [ + 6 => [ + 'title' => 'stage - 4 - r6 - published', + 'status' => FALSE, + 'default_revision' => FALSE, + ], + 7 => [ + 'title' => 'stage - 4 - r6 - published', + 'status' => TRUE, + 'default_revision' => TRUE, + ], + ], + ], + ]); + $test_scenarios['add_published_node_in_stage'] = $revision_state; + + // Deploying 'stage' to 'live' should simply make the latest revisions in + // 'stage' the default ones in 'live'. + $revision_state = array_replace_recursive($revision_state, [ + 'live' => [ + 1 => [ + 1 => ['default_revision' => FALSE], + 3 => ['default_revision' => TRUE], + ], + 2 => [ + 2 => ['default_revision' => FALSE], + 4 => ['default_revision' => TRUE], + ], + // Node 3 has a single revision for both 'stage' and 'live' and it is + // already the default revision in both of them. + 4 => [ + 6 => ['default_revision' => FALSE], + 7 => ['default_revision' => TRUE], + ], + ], + ]); + $test_scenarios['deploy_stage_to_live'] = $revision_state; + + // Check the initial state after the module was installed. + $this->assertWorkspaceStatus($test_scenarios['initial_state'], 'node'); + + // Unpublish node 1 in 'stage'. + $this->switchToWorkspace('stage'); + $node = $this->entityTypeManager->getStorage('node')->load(1); + $node->setTitle('stage - 1 - r3 - unpublished'); + $node->setUnpublished(); + $node->save(); + $this->assertWorkspaceStatus($test_scenarios['unpublish_node_1_in_stage'], 'node'); + + // Publish node 2 in 'stage'. + $this->switchToWorkspace('stage'); + $node = $this->entityTypeManager->getStorage('node')->load(2); + $node->setTitle('stage - 2 - r4 - published'); + $node->setPublished(); + $node->save(); + $this->assertWorkspaceStatus($test_scenarios['publish_node_2_in_stage'], 'node'); + + // Add a new unpublished node on 'stage'. + $this->switchToWorkspace('stage'); + $this->createNode(['title' => 'stage - 3 - r5 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]); + $this->assertWorkspaceStatus($test_scenarios['add_unpublished_node_in_stage'], 'node'); + + // Add a new published node on 'stage'. + $this->switchToWorkspace('stage'); + $this->createNode(['title' => 'stage - 4 - r6 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]); + $this->assertWorkspaceStatus($test_scenarios['add_published_node_in_stage'], 'node'); + + // Deploy 'stage' to 'live'. + $repository_handler = $this->workspaces['stage']->getRepositoryHandlerPlugin(); + $repository_handler->replicate($this->workspaces['stage']->getLocalRepositoryHandlerPlugin(), $repository_handler); + $this->assertWorkspaceStatus($test_scenarios['deploy_stage_to_live'], 'node'); + } + + /** + * Tests the Entity Query relationship API with workspaces. + */ + public function testEntityQueryRelationship() { + $this->initializeWorkspaceModule(); + + // Add an entity reference field that targets 'entity_test_mulrev' entities. + $this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrev'); + + // Add an entity reference field that targets 'node' entities so we can test + // references to the same base tables. + $this->createEntityReferenceField('node', 'page', 'field_test_node', 'Test node reference', 'node'); + + $this->switchToWorkspace('live'); + $node_1 = $this->createNode([ + 'title' => 'live node 1' + ]); + $entity_test = EntityTestMulRev::create([ + 'name' => 'live entity_test_mulrev', + 'non_rev_field' => 'live non-revisionable value', + ]); + $entity_test->save(); + + $node_2 = $this->createNode([ + 'title' => 'live node 2', + 'field_test_entity' => $entity_test->id(), + 'field_test_node' => $node_1->id(), + ]); + + // Switch to the 'stage' workspace and change some values for the referenced + // entities. + $this->switchToWorkspace('stage'); + $node_1->title->value = 'stage node 1'; + $node_1->save(); + + $node_2->title->value = 'stage node 2'; + $node_2->save(); + + $entity_test->name->value = 'stage entity_test_mulrev'; + $entity_test->non_rev_field->value = 'stage non-revisionable value'; + $entity_test->save(); + + // Make sure that we're requesting the default revision. + $query = $this->entityTypeManager->getStorage('node')->getQuery(); + $query->currentRevision(); + + $query + // Check a condition on the revision data table. + ->condition('title', 'stage node 2') + // Check a condition on the revision table. + ->condition('revision_uid', $node_2->getRevisionUserId()) + // Check a condition on the data table. + ->condition('type', $node_2->bundle()) + // Check a condition on the base table. + ->condition('uuid', $node_2->uuid()); + + // Add conditions for a reference to the same entity type. + $query + // Check a condition on the revision data table. + ->condition('field_test_node.entity.title', 'stage node 1') + // Check a condition on the revision table. + ->condition('field_test_node.entity.revision_uid', $node_1->getRevisionUserId()) + // Check a condition on the data table. + ->condition('field_test_node.entity.type', $node_1->bundle()) + // Check a condition on the base table. + ->condition('field_test_node.entity.uuid', $node_1->uuid()); + + // Add conditions for a reference to a different entity type. + $query + // Check a condition on the revision data table. + ->condition('field_test_entity.entity.name', 'stage entity_test_mulrev') + // Check a condition on the data table. + ->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value') + // Check a condition on the base table. + ->condition('field_test_entity.entity.uuid', $entity_test->uuid()); + + $result = $query->execute(); + $this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result); + } + + /** + * Checks entity load, entity queries and views results for a test scenario. + * + * @param array $expected + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type that is being tested. + */ + protected function assertWorkspaceStatus(array $expected, $entity_type_id) { + $expected = $this->flattenExpectedValues($expected, $entity_type_id); + + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + foreach ($expected as $workspace_id => $expected_values) { + $this->switchToWorkspace($workspace_id); + + // Check that default revisions are swapped with the workspace revision. + $this->assertEntityLoad($expected_values, $entity_type_id); + + // Check that non-default revisions are not changed. + $this->assertEntityRevisionLoad($expected_values, $entity_type_id); + + // Check that entity queries return the correct results. + $this->assertEntityQuery($expected_values, $entity_type_id); + + // Check that the 'Frontpage' view only shows published content that is + // also considered as the default revision in the given workspace. + $expected_frontpage = array_filter($expected_values, function ($expected_value) { + return $expected_value['status'] === TRUE && $expected_value['default_revision'] === TRUE; + }); + // The 'Frontpage' view will output nodes in reverse creation order. + usort($expected_frontpage, function ($a, $b) { + return $b['nid'] - $a['nid']; + }); + $view = Views::getView('frontpage'); + $view->execute(); + $this->assertIdenticalResultset($view, $expected_frontpage, ['nid' => 'nid']); + + $rendered_view = $view->render('page_1'); + $output = \Drupal::service('renderer')->renderRoot($rendered_view); + $this->setRawContent($output); + foreach ($expected_values as $expected_entity_values) { + if ($expected_entity_values[$entity_keys['published']] === TRUE && $expected_entity_values['default_revision'] === TRUE) { + $this->assertRaw($expected_entity_values[$entity_keys['label']]); + } + // Node 4 will always appear in the 'stage' workspace because it has + // both an unpublished revision as well as a published one. + elseif ($workspace_id != 'stage' && $expected_entity_values[$entity_keys['id']] != 4) { + $this->assertNoRaw($expected_entity_values[$entity_keys['label']]); + } + } + } + } + + /** + * Asserts that default revisions are properly swapped in a workspace. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityLoad(array $expected_values, $entity_type_id) { + // Filter the expected values so we can check only the default revisions. + $expected_default_revisions = array_filter($expected_values, function ($expected_value) { + return $expected_value['default_revision'] === TRUE; + }); + + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + // Check \Drupal\Core\Entity\EntityStorageInterface::loadMultiple(). + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple(array_column($expected_default_revisions, $id_key)); + foreach ($expected_default_revisions as $expected_default_revision) { + $entity_id = $expected_default_revision[$id_key]; + $this->assertEquals($expected_default_revision[$revision_key], $entities[$entity_id]->getRevisionId()); + $this->assertEquals($expected_default_revision[$label_key], $entities[$entity_id]->label()); + $this->assertEquals($expected_default_revision[$published_key], $entities[$entity_id]->isPublished()); + } + + // Check \Drupal\Core\Entity\EntityStorageInterface::loadUnchanged(). + foreach ($expected_default_revisions as $expected_default_revision) { + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadUnchanged($expected_default_revision[$id_key]); + $this->assertEquals($expected_default_revision[$revision_key], $entity->getRevisionId()); + $this->assertEquals($expected_default_revision[$label_key], $entity->label()); + $this->assertEquals($expected_default_revision[$published_key], $entity->isPublished()); + } + } + + /** + * Asserts that non-default revisions are not changed. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityRevisionLoad(array $expected_values, $entity_type_id) { + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */ + $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_column($expected_values, $revision_key)); + foreach ($expected_values as $expected_revision) { + $revision_id = $expected_revision[$revision_key]; + $this->assertEquals($expected_revision[$id_key], $entities[$revision_id]->id()); + $this->assertEquals($expected_revision[$revision_key], $entities[$revision_id]->getRevisionId()); + $this->assertEquals($expected_revision[$label_key], $entities[$revision_id]->label()); + $this->assertEquals($expected_revision[$published_key], $entities[$revision_id]->isPublished()); + } + } + + /** + * Asserts that entity queries are giving the correct results in a workspace. + * + * @param array $expected_values + * An array of expected values, as defined in ::testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type to check. + */ + protected function assertEntityQuery(array $expected_values, $entity_type_id) { + $storage = $this->entityTypeManager->getStorage($entity_type_id); + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + $id_key = $entity_keys['id']; + $revision_key = $entity_keys['revision']; + $label_key = $entity_keys['label']; + $published_key = $entity_keys['published']; + + // Filter the expected values so we can check only the default revisions. + $expected_default_revisions = array_filter($expected_values, function ($expected_value) { + return $expected_value['default_revision'] === TRUE; + }); + + // Check entity query counts. + $result = $storage->getQuery()->count()->execute(); + $this->assertEquals(count($expected_default_revisions), $result); + + $result = $storage->getAggregateQuery()->count()->execute(); + $this->assertEquals(count($expected_default_revisions), $result); + + // Check entity queries with no conditions. + $result = $storage->getQuery()->execute(); + $expected_result = array_combine(array_column($expected_default_revisions, $revision_key), array_column($expected_default_revisions, $id_key)); + $this->assertEquals($expected_result, $result); + + // Check querying each revision individually. + foreach ($expected_values as $expected_value) { + $query = $storage->getQuery(); + $query + ->condition($entity_keys['id'], $expected_value[$id_key]) + ->condition($entity_keys['label'], $expected_value[$label_key]) + ->condition($entity_keys['published'], $expected_value[$published_key]); + + // If the entity is not expected to be the default revision, we need to + // query all revisions if we want to find it. + if (!$expected_value['default_revision']) { + $query->allRevisions(); + } + + $result = $query->execute(); + $this->assertEquals([$expected_value[$revision_key] => $expected_value[$id_key]], $result); + } + } + + /** + * Sets a given workspace as active. + * + * @param string $workspace_id + * The ID of the workspace to switch to. + */ + protected function switchToWorkspace($workspace_id) { + /** @var \Drupal\workspace\WorkspaceManager $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + if ($workspace_manager->getActiveWorkspace()->id() !== $workspace_id) { + // Switch the test runner's context to the specified workspace. + $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); + \Drupal::service('workspace.manager')->setActiveWorkspace($workspace); + } + } + + /** + * Flattens the expectations array defined by testWorkspaces(). + * + * @param array $expected + * An array as defined by testWorkspaces(). + * @param string $entity_type_id + * The ID of the entity type that is being tested. + * + * @return array + * An array where all the entity IDs and revision IDs are merged inside each + * expected values array. + */ + protected function flattenExpectedValues(array $expected, $entity_type_id) { + $flattened = []; + + $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys(); + foreach ($expected as $workspace_id => $workspace_values) { + foreach ($workspace_values as $entity_id => $entity_revisions) { + foreach ($entity_revisions as $revision_id => $revision_values) { + $flattened[$workspace_id][] = [$entity_keys['id'] => $entity_id, $entity_keys['revision'] => $revision_id] + $revision_values; + } + } + } + + return $flattened; + } + +} diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php new file mode 100644 index 0000000..632140a --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php @@ -0,0 +1,62 @@ +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(); + } + + /** + * Tests enabling replication logs for REST throws an exception. + * + * @see workspace_rest_resource_alter() + */ + public function testCreateReplicationLogResource() { + $this->setExpectedException(PluginNotFoundException::class, 'The "entity:replication_log" plugin does not exist.'); + RestResourceConfig::create([ + 'id' => 'entity.replication_log', + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['GET'], + 'formats' => ['json'], + 'authentication' => ['cookie'], + ], + ]) + ->enable() + ->save(); + } + +} diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml new file mode 100644 index 0000000..9599e07 --- /dev/null +++ b/core/modules/workspace/workspace.info.yml @@ -0,0 +1,9 @@ +name: Workspace +type: module +description: 'Provides the ability to have multiple workspaces on a single site to facilitate things like full-site preview and content staging.' +version: VERSION +core: 8.x +package: Core (Experimental) +configure: entity.workspace.collection +dependencies: + - user diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install new file mode 100644 index 0000000..0b3549e --- /dev/null +++ b/core/modules/workspace/workspace.install @@ -0,0 +1,47 @@ +getStorage('user_role')->getQuery() + ->condition('is_admin', TRUE) + ->execute(); + if (!empty($admin_roles)) { + $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery() + ->condition('roles', $admin_roles, 'IN') + ->condition('status', 1) + ->sort('uid', 'ASC') + ->range(0, 1); + $result = $query->execute(); + } + + // Default to user ID 1 if we could not find any other administrator users. + $owner_id = !empty($result) ? reset($result) : 1; + + // Create two workspaces by default, 'live' and 'stage'. + Workspace::create([ + 'id' => 'live', + 'label' => 'Live', + 'upstream' => RepositoryHandlerInterface::EMPTY_VALUE, + 'uid' => $owner_id, + ])->save(); + + Workspace::create([ + 'id' => 'stage', + 'label' => 'Stage', + 'upstream' => 'local_workspace:live', + 'uid' => $owner_id, + ])->save(); +} diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml new file mode 100644 index 0000000..c7aad42 --- /dev/null +++ b/core/modules/workspace/workspace.libraries.yml @@ -0,0 +1,5 @@ +drupal.workspace.toolbar: + version: VERSION + css: + theme: + css/workspace.toolbar.css: {} diff --git a/core/modules/workspace/workspace.link_relation_types.yml b/core/modules/workspace/workspace.link_relation_types.yml new file mode 100644 index 0000000..d591603 --- /dev/null +++ b/core/modules/workspace/workspace.link_relation_types.yml @@ -0,0 +1,8 @@ +# Workspace extension relation types. +# See https://tools.ietf.org/html/rfc5988#section-4.2. +activate-form: + uri: https://drupal.org/link-relations/activate-form + description: A form where a workspace can be activated. +deploy-form: + uri: https://drupal.org/link-relations/deploy-form + description: A form where a workspace can be deployed. diff --git a/core/modules/workspace/workspace.links.action.yml b/core/modules/workspace/workspace.links.action.yml new file mode 100644 index 0000000..9f22598 --- /dev/null +++ b/core/modules/workspace/workspace.links.action.yml @@ -0,0 +1,5 @@ +entity.workspace.add_form: + route_name: entity.workspace.add_form + title: 'Add workspace' + appears_on: + - entity.workspace.collection diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml new file mode 100644 index 0000000..c7faefb --- /dev/null +++ b/core/modules/workspace/workspace.links.menu.yml @@ -0,0 +1,5 @@ +entity.workspace.collection: + title: 'Workspaces' + parent: system.admin_config_workflow + description: 'Create and manage workspaces.' + route_name: entity.workspace.collection diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module new file mode 100644 index 0000000..708aad4 --- /dev/null +++ b/core/modules/workspace/workspace.module @@ -0,0 +1,234 @@ +' . t('About') . ''; + $output .= '

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

'; + return $output; + } +} + +/** + * Implements hook_entity_load(). + */ +function workspace_entity_load(array &$entities, $entity_type_id) { + 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 and ReplicationLog are internal entity types, + // therefore they should not be exposed via REST. + unset($definitions['entity:workspace_association']); + unset($definitions['entity:replication_log']); +} + +/** + * Implements hook_toolbar(). + */ +function workspace_toolbar() { + $items = []; + $items['workspace'] = [ + '#cache' => [ + 'contexts' => [ + 'user.permissions', + ], + ], + ]; + + $current_user = \Drupal::currentUser(); + if (!$current_user->hasPermission('administer workspaces') + || !$current_user->hasPermission('view own workspace') + || !$current_user->hasPermission('view any workspace')) { + return $items; + } + + /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */ + $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(); + + $configure_link = NULL; + if ($current_user->hasPermission('administer workspaces')) { + $configure_link = [ + '#type' => 'link', + '#title' => t('Manage workspaces'), + '#url' => $active_workspace->toUrl('collection'), + '#options' => ['attributes' => ['class' => ['manage-workspaces']]], + ]; + } + + $items['workspace'] = [ + '#type' => 'toolbar_item', + 'tab' => [ + '#type' => 'link', + '#title' => $active_workspace->label(), + '#url' => $active_workspace->toUrl('collection'), + '#attributes' => [ + 'title' => t('Switch workspace'), + 'class' => ['toolbar-icon', 'toolbar-icon-workspace'], + ], + ], + 'tray' => [ + '#heading' => t('Workspaces'), + 'workspaces' => workspace_renderable_links(), + 'configure' => $configure_link, + ], + '#wrapper_attributes' => [ + 'class' => ['workspace-toolbar-tab'], + ], + '#attached' => [ + 'library' => ['workspace/drupal.workspace.toolbar'], + ], + '#weight' => 500, + ]; + + // Add a special class to the wrapper if we are in the default workspace so we + // can highlight it with a different color. + if ($active_workspace->isDefaultWorkspace()) { + $items['workspace']['#wrapper_attributes']['class'][] = 'is-live'; + } + + return $items; +} + +/** + * Returns an array of workspace activation form links, suitable for rendering. + * + * @return array + * A render array containing links to the workspace activation form. + */ +function workspace_renderable_links() { + $entity_type_manager = \Drupal::entityTypeManager(); + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + $entity_repository = \Drupal::service('entity.repository'); + /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */ + $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(); + + $links = $cache_tags = []; + foreach ($entity_type_manager->getStorage('workspace')->loadMultiple() as $workspace) { + $workspace = $entity_repository->getTranslationFromContext($workspace); + + // Add the 'is-active' class for the currently active workspace. + $options = []; + if ($workspace->id() === $active_workspace->id()) { + $options['attributes']['class'][] = 'is-active'; + } + + // Get the URL of the workspace activation form and display it in a modal. + $url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $workspace->id()], $options); + if ($url->access()) { + $links[$workspace->id()] = [ + 'type' => 'link', + 'title' => $workspace->label(), + 'url' => $url, + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 500, + ]), + ], + ]; + $cache_tags = Cache::mergeTags($cache_tags, $workspace->getCacheTags()); + } + } + + if (!empty($links)) { + $links = [ + '#theme' => 'links__toolbar_workspaces', + '#links' => $links, + '#attributes' => [ + 'class' => ['toolbar-menu'], + ], + '#cache' => [ + 'tags' => $cache_tags, + ], + ]; + } + + return $links; +} diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml new file mode 100644 index 0000000..36d21aa --- /dev/null +++ b/core/modules/workspace/workspace.permissions.yml @@ -0,0 +1,28 @@ +administer workspaces: + title: Administer workspaces + +create workspace: + title: Create a new workspace + +view own workspace: + title: View own workspace + +view any workspace: + title: View any workspace + +edit own workspace: + title: Edit own workspace + +edit any workspace: + title: Edit any workspace + +bypass entity access own workspace: + title: Bypass content entity access in own workspace + description: Allow all Edit/Update/Delete permissions for all content entities in a workspace owned by the user. + restrict access: TRUE + +update any workspace from upstream: + title: Update any workspace from upstream + +permission_callbacks: + - Drupal\workspace\EntityAccess::workspacePermissions diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml new file mode 100644 index 0000000..2226501 --- /dev/null +++ b/core/modules/workspace/workspace.routing.yml @@ -0,0 +1,27 @@ +entity.workspace.collection: + path: '/admin/config/workflow/workspace' + defaults: + _title: 'Workspaces' + _entity_list: 'workspace' + requirements: + _permission: 'administer workspaces+edit any workspace' + +entity.workspace.activate_form: + path: '/admin/config/workflow/workspace/{workspace}/activate' + defaults: + _entity_form: 'workspace.activate' + _title: 'Activate Workspace' + options: + _admin_route: TRUE + requirements: + _entity_access: 'workspace.view' + +entity.workspace.deploy_form: + path: '/admin/config/workflow/workspace/{workspace}/deploy' + defaults: + _entity_form: 'workspace.deploy' + _title: 'Deploy Workspace' + options: + _admin_route: TRUE + requirements: + _permission: 'administer workspaces' diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml new file mode 100644 index 0000000..f68e0eb --- /dev/null +++ b/core/modules/workspace/workspace.services.yml @@ -0,0 +1,27 @@ +services: + workspace.manager: + class: Drupal\workspace\WorkspaceManager + arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@logger.channel.workspace', '@class_resolver'] + tags: + - { name: service_id_collector, tag: workspace_negotiator } + plugin.manager.workspace.repository_handler: + class: Drupal\workspace\RepositoryHandlerManager + parent: default_plugin_manager + workspace.negotiator.default: + class: Drupal\workspace\Negotiator\DefaultWorkspaceNegotiator + arguments: ['@entity_type.manager'] + tags: + - { name: workspace_negotiator, priority: 0 } + workspace.negotiator.session: + class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator + arguments: ['@current_user', '@user.private_tempstore', '@entity_type.manager'] + tags: + - { name: workspace_negotiator, priority: 100 } + cache_context.workspace: + class: Drupal\workspace\WorkspaceCacheContext + arguments: ['@workspace.manager'] + tags: + - { name: cache.context } + logger.channel.workspace: + parent: logger.channel_base + arguments: ['workspace'] diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php index b3af27f..8545efe 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityAccessControlHandler; use Drupal\Core\Session\AnonymousUserSession; use Drupal\entity_test\Entity\EntityTest; +use Drupal\entity_test\Entity\EntityTestStringId; use Drupal\entity_test\Entity\EntityTestDefaultAccess; use Drupal\entity_test\Entity\EntityTestNoUuid; use Drupal\entity_test\Entity\EntityTestLabel; @@ -18,6 +19,7 @@ /** * Tests the entity access control handler. * + * @coversDefaultClass \Drupal\Core\Entity\EntityAccessControlHandler * @group Entity */ class EntityAccessControlHandlerTest extends EntityLanguageTestBase { @@ -30,6 +32,7 @@ public function setUp() { $this->installEntitySchema('entity_test_no_uuid'); $this->installEntitySchema('entity_test_rev'); + $this->installEntitySchema('entity_test_string_id'); } /** @@ -293,4 +296,73 @@ public function testHooks() { $this->assertEqual($state->get('entity_test_entity_test_access'), TRUE); } + /** + * Tests the default access handling for the ID and UUID fields. + * + * @covers ::fieldAccess + * @dataProvider providerTestFieldAccess + */ + public function testFieldAccess($entity_class, array $entity_create_values, $expected_id_create_access) { + // Set up a non-admin user that is allowed to create and update test + // entities. + \Drupal::currentUser()->setAccount($this->createUser(['uid' => 2], ['administer entity_test content'])); + + // Create the entity to test field access with. + $entity = $entity_class::create($entity_create_values); + + // On newly-created entities, field access must allow setting the UUID + // field. + $this->assertTrue($entity->get('uuid')->access('edit')); + $this->assertTrue($entity->get('uuid')->access('edit', NULL, TRUE)->isAllowed()); + // On newly-created entities, field access will not allow setting the ID + // field if the ID is of type serial. It will allow access if it is of type + // string. + $this->assertEquals($expected_id_create_access, $entity->get('id')->access('edit')); + $this->assertEquals($expected_id_create_access, $entity->get('id')->access('edit', NULL, TRUE)->isAllowed()); + + // Save the entity and check that we can not update the ID or UUID fields + // anymore. + $entity->save(); + + // If the ID has been set as part of the create ensure it has been set + // correctly. + if (isset($entity_create_values['id'])) { + $this->assertSame($entity_create_values['id'], $entity->id()); + } + // The UUID is hard-coded by the data provider. + $this->assertSame('60e3a179-79ed-4653-ad52-5e614c8e8fbe', $entity->uuid()); + $this->assertFalse($entity->get('uuid')->access('edit')); + $access_result = $entity->get('uuid')->access('edit', NULL, TRUE); + $this->assertTrue($access_result->isForbidden()); + $this->assertEquals('The entity UUID cannot be changed', $access_result->getReason()); + + // Ensure the ID is still not allowed to be edited. + $this->assertFalse($entity->get('id')->access('edit')); + $access_result = $entity->get('id')->access('edit', NULL, TRUE); + $this->assertTrue($access_result->isForbidden()); + $this->assertEquals('The entity ID cannot be changed', $access_result->getReason()); + } + + public function providerTestFieldAccess() { + return [ + 'serial ID entity' => [ + EntityTest::class, + [ + 'name' => 'A test entity', + 'uuid' => '60e3a179-79ed-4653-ad52-5e614c8e8fbe', + ], + FALSE + ], + 'string ID entity' => [ + EntityTestStringId::class, + [ + 'id' => 'a_test_entity', + 'name' => 'A test entity', + 'uuid' => '60e3a179-79ed-4653-ad52-5e614c8e8fbe', + ], + TRUE + ], + ]; + } + }