diff --git a/core/composer.json b/core/composer.json index 1f25c46635..36775ad1c7 100644 --- a/core/composer.json +++ b/core/composer.json @@ -149,6 +149,7 @@ "drupal/views": "self.version", "drupal/views_ui": "self.version", "drupal/workflows": "self.version" + "drupal/workspace": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/core/modules/config/src/Tests/ConfigImportAllTest.php b/core/modules/config/src/Tests/ConfigImportAllTest.php index 459be7a24f..980fb8e68f 100644 --- a/core/modules/config/src/Tests/ConfigImportAllTest.php +++ b/core/modules/config/src/Tests/ConfigImportAllTest.php @@ -7,6 +7,7 @@ use Drupal\system\Tests\Module\ModuleTestBase; use Drupal\shortcut\Entity\Shortcut; use Drupal\taxonomy\Entity\Term; +use Drupal\workspace\Entity\Workspace; /** * Tests the largest configuration import possible with all available modules. @@ -92,6 +93,10 @@ public function testInstallUninstall() { $shortcuts = Shortcut::loadMultiple(); entity_delete_multiple('shortcut', array_keys($shortcuts)); + // Delete any workspaces so the workspace module can be uninstalled. + $workspaces = Workspace::loadMultiple(); + \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces); + system_list_reset(); $all_modules = system_rebuild_module_data(); diff --git a/core/modules/system/src/Tests/Module/InstallUninstallTest.php b/core/modules/system/src/Tests/Module/InstallUninstallTest.php index f4db81e18e..c86deaeb6e 100644 --- a/core/modules/system/src/Tests/Module/InstallUninstallTest.php +++ b/core/modules/system/src/Tests/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/css/workspace.admin.css b/core/modules/workspace/css/workspace.admin.css new file mode 100644 index 0000000000..31b757d7ba --- /dev/null +++ b/core/modules/workspace/css/workspace.admin.css @@ -0,0 +1,91 @@ +.item-list ul.workspace, +.item-list ul.workspace ul { + padding-top: 20px; + position: relative; + margin: 0; +} + +.item-list .workspace li { + float: left; + text-align: center; + list-style-type: none; + position: relative; + padding: 20px 5px 0; + margin: 0; +} + +/* We will use ::before and ::after to draw the connectors */ +.item-list .workspace li::before, +.item-list .workspace li::after { + content: ''; + position: absolute; + top: 0; + right: 50%; + border-top: 1px solid #ccc; + width: 50%; + height: 20px; +} +.item-list .workspace li::after { + right: auto; + left: 50%; + border-left: 1px solid #ccc; +} + +/* We need to remove left-right connectors from elements without any siblings */ +.item-list .workspace li:only-child::after, +.item-list .workspace li:only-child::before { + display: none; +} + +/* Remove space from the top of single children */ +.item-list .workspace li:only-child{ + padding-top: 0; +} + +/* Remove left connector from first child and right connector from last child*/ +.item-list .workspace li:first-child::before, +.item-list .workspace li:last-child::after { + border: 0 none; +} + +/* Adding back the vertical connector to the last nodes */ +.item-list .workspace li:last-child::before { + border-right: 1px solid #ccc; +} + +/* Downward connector from parents */ +.item-list ul.workspace ul::before, +.item-list ul.workspace ul ul::before { + content: ''; + position: absolute; top: 0; left: 50%; + border-left: 1px solid #ccc; + width: 0; + height: 20px; +} + +.item-list .workspace .rev, +.item-list .workspace .rev__title { + display: inline-block; + margin: 0; +} + +.item-list .workspace .panel__title { + padding: 0; +} + +.item-list .workspace hr { + margin: 5px 0; +} + +.rev { + margin: 0px 0px 20px; + padding: 9px; + background: #F8F8F8; + border: 1px solid #CCC +} + +.rev__title { + font-size: 1em; + text-transform: uppercase; + margin: 0px; +} \ No newline at end of file diff --git a/core/modules/workspace/css/workspace.switcher.css b/core/modules/workspace/css/workspace.switcher.css new file mode 100644 index 0000000000..a833a6bccb --- /dev/null +++ b/core/modules/workspace/css/workspace.switcher.css @@ -0,0 +1,26 @@ +/** + * @file + * Styling for switcher block. + */ + +#block-workspaceswitcher input[type="submit"] { + background: transparent; + border: 0; + border-radius: 0; + padding: 0.2em 0 0 0 ; + margin: 0; + color: #0071b3; + border-bottom: 1px dotted; + text-decoration: none; + font-family: Georgia, "Times New Roman", Times, serif; + font-size: 1em; +} + +#block-workspaceswitcher input[type="submit"]:hover { + border-bottom-style: solid; + color: #018fe2; +} + +#block-workspaceswitcher input[type="submit"].is-active { + color: #000000; +} diff --git a/core/modules/workspace/css/workspace.toolbar.css b/core/modules/workspace/css/workspace.toolbar.css new file mode 100644 index 0000000000..03636ced67 --- /dev/null +++ b/core/modules/workspace/css/workspace.toolbar.css @@ -0,0 +1,80 @@ +/** + * @file + * Styling for Workspace toolbar. + */ + +.toolbar .toolbar-icon-workspace:before { + background-image: url("../icons/bebebe/workspace.svg"); +} + +.toolbar .toolbar-icon-workspace.is-active:before { + background-image: url("../icons/ffffff/workspace.svg"); +} + +.toolbar .toolbar-icon-workspace-update:before { + background-image: url("../icons/bebebe/update.svg"); +} + +.toolbar .toolbar-icon-workspace-update.is-active:before { + background-image: url("../icons/ffffff/update.svg"); +} + +.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab, +.toolbar .toolbar-bar .workspace-update-toolbar-tab.toolbar-tab, +.toolbar .toolbar-bar .workspace-deploy-toolbar-tab.toolbar-tab { + float: right; +} + +.toolbar #toolbar-item-workspace-switcher-tray input[type="submit"], +.workspace-deploy-toolbar-tab input[type="submit"] { + display: block; + background: transparent; + border: 0; + border-radius: 0; + margin: 0; + padding: 1em 1.3333em; + font-weight: bold; + font-size: 1em; + line-height: 1; + color: #DDDDDD; +} + +.workspace-deploy-toolbar-tab input[type="submit"]:hover, +.workspace-deploy-toolbar-tab input[type="submit"]:focus { + background-image: linear-gradient(rgba(255,255,255,0.125) 20%,transparent 200%); + background-color: transparent; +} + +.toolbar #toolbar-item-workspace-switcher-tray input[type="submit"] { + color: #565656; + font-weight: normal; +} + +.toolbar #toolbar-item-workspace-switcher-tray input[type="submit"]:hover { + text-decoration: underline; + color: #000000; + box-shadow: none; +} + +.toolbar #toolbar-item-workspace-switcher-tray input[type="submit"].is-active { + color: #000000; + font-weight: bold; + text-decoration: underline; +} + +.toolbar #toolbar-item-workspace-switcher-tray.toolbar-tray-horizontal input[type="submit"] { + float: left; +} + +.toolbar #toolbar-item-workspace-switcher-tray.toolbar-tray-vertical input[type="submit"] { + padding-left: 2.75em; + padding-right: 4em; +} + +.toolbar #toolbar-item-workspace-switcher-tray.toolbar-tray-vertical form { + background-color: #ffffff; +} + +.toolbar #toolbar-item-workspace-switcher-tray a.add-workspace { + float: right; +} diff --git a/core/modules/workspace/icons/bebebe/update.svg b/core/modules/workspace/icons/bebebe/update.svg new file mode 100644 index 0000000000..c114dea91b --- /dev/null +++ b/core/modules/workspace/icons/bebebe/update.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/modules/workspace/icons/bebebe/workspace.svg b/core/modules/workspace/icons/bebebe/workspace.svg new file mode 100644 index 0000000000..0d893f3d28 --- /dev/null +++ b/core/modules/workspace/icons/bebebe/workspace.svg @@ -0,0 +1,12 @@ + + + + Combined Shape + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/core/modules/workspace/icons/ffffff/update.svg b/core/modules/workspace/icons/ffffff/update.svg new file mode 100644 index 0000000000..61bba5808e --- /dev/null +++ b/core/modules/workspace/icons/ffffff/update.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/core/modules/workspace/icons/ffffff/workspace.svg b/core/modules/workspace/icons/ffffff/workspace.svg new file mode 100644 index 0000000000..584ec8ccae --- /dev/null +++ b/core/modules/workspace/icons/ffffff/workspace.svg @@ -0,0 +1,12 @@ + + + + Combined Shape + Created with Sketch. + + + + + + + diff --git a/core/modules/workspace/src/Access/WorkspaceViewCheck.php b/core/modules/workspace/src/Access/WorkspaceViewCheck.php new file mode 100644 index 0000000000..8d45be1df1 --- /dev/null +++ b/core/modules/workspace/src/Access/WorkspaceViewCheck.php @@ -0,0 +1,29 @@ +access('view', $account))->addCacheableDependency($workspace); + } +} diff --git a/core/modules/workspace/src/Annotation/Upstream.php b/core/modules/workspace/src/Annotation/Upstream.php new file mode 100644 index 0000000000..f306917eeb --- /dev/null +++ b/core/modules/workspace/src/Annotation/Upstream.php @@ -0,0 +1,21 @@ +workspaceId = $workspace->id(); + $this->entityTypeManager = $entity_type_manager; + $this->sequenceIndex = $sequence_index; + } + + /** + * {@inheritdoc} + */ + public function includeDocs($include_docs) { + $this->includeDocs = $include_docs; + return $this; + } + + /** + * {@inheritdoc} + */ + public function lastSeq($seq) { + $this->lastSeq = $seq; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getNormal() { + $sequences = $this->sequenceIndex + ->useWorkspace($this->workspaceId) + ->getRange($this->lastSeq, NULL); + + // Format the result array. + $changes = []; + foreach ($sequences as $sequence) { + // Get the document. + $revision = NULL; + if ($this->includeDocs == TRUE) { + /** @var \Drupal\multiversion\Entity\Storage\ContentEntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($sequence['entity_type_id']); + $storage->useWorkspace($this->workspaceId); + $revision = $storage->loadRevision($sequence['revision_id']); + } + + $changes[$sequence['entity_uuid']] = [ + 'changes' => [ + ['rev' => $sequence['revision_id']], + ], + 'id' => $sequence['entity_id'], + 'type' => $sequence['entity_type_id'], + 'seq' => $sequence['seq'], + ]; + + // Include the document. + if ($this->includeDocs == TRUE) { + $changes[$sequence['entity_uuid']]['doc'] = $revision; + } + } + + // Now when we have rebuilt the result array we need to ensure that the + // results array is still sorted on the sequence key, as in the index. + $return = array_values($changes); + usort($return, function($a, $b) { + return $a['seq'] - $b['seq']; + }); + + return $return; + } + + /** + * {@inheritdoc} + */ + public function getLongpoll() { + $no_change = TRUE; + do { + $change = $this->sequenceIndex + ->useWorkspace($this->workspaceId) + ->getRange($this->lastSeq, NULL); + $no_change = empty($change) ? TRUE : FALSE; + } while ($no_change); + return $change; + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Changes/ChangesFactory.php b/core/modules/workspace/src/Changes/ChangesFactory.php new file mode 100644 index 0000000000..de1da503f2 --- /dev/null +++ b/core/modules/workspace/src/Changes/ChangesFactory.php @@ -0,0 +1,48 @@ +entityTypeManager = $entity_type_manager; + $this->sequenceIndex = $sequence_index; + } + /** + * {@inheritdoc} + */ + public function get(WorkspaceInterface $workspace) { + if (!isset($this->instances[$workspace->id()])) { + $this->instances[$workspace->id()] = new Changes( + $workspace, + $this->entityTypeManager, + $this->sequenceIndex + ); + } + return $this->instances[$workspace->id()]; + } +} \ No newline at end of file diff --git a/core/modules/workspace/src/Changes/ChangesFactoryInterface.php b/core/modules/workspace/src/Changes/ChangesFactoryInterface.php new file mode 100644 index 0000000000..58dce7c059 --- /dev/null +++ b/core/modules/workspace/src/Changes/ChangesFactoryInterface.php @@ -0,0 +1,21 @@ +workspaceManager = $workspace_manager; + $this->formBuilder = $form_builder; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('workspace.manager'), + $container->get('form_builder') + ); + } + + /** + * @return array + */ + public function workspaces() { + $active_workspace_id = $this->workspaceManager->getActiveWorkspace(); + $workspaces = Workspace::loadMultiple(); + $active_workspace = $workspaces[$active_workspace_id]; + unset($workspaces[$active_workspace_id]); + return $this->formBuilder->getForm(DeploymentForm::class, $active_workspace); + return [ + '#theme' => 'workspace_deployment', + '#active_workspace' => $active_workspace, + '#workspaces' => $workspaces, + '#deploy' => $deploy, + '#attached' => [ + 'library' => [ + 'workspace/drupal.workspace.deployment', + ], + ], + '#cache' => [ + 'contexts' => ['workspace'], + ], + ]; + } +} \ No newline at end of file diff --git a/core/modules/workspace/src/Entity/ContentWorkspace.php b/core/modules/workspace/src/Entity/ContentWorkspace.php new file mode 100644 index 0000000000..63573e307f --- /dev/null +++ b/core/modules/workspace/src/Entity/ContentWorkspace.php @@ -0,0 +1,177 @@ +setLabel(t('User')) + ->setDescription(t('The username of the entity creator.')) + ->setSetting('target_type', 'user') + ->setDefaultValueCallback('Drupal\workspace\Entity\ContentWorkspace::getCurrentUserId') + ->setTranslatable(TRUE) + ->setRevisionable(TRUE); + + $fields['workspace'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('workspace')) + ->setDescription(t('The workspace of the referenced content.')) + ->setSetting('target_type', 'workspace') + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->addConstraint('workspace', []); + + $fields['content_entity_type_id'] = BaseFieldDefinition::create('string') + ->setLabel(t('Content entity type ID')) + ->setDescription(t('The ID of the content entity type this workspace is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + $fields['content_entity_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Content entity ID')) + ->setDescription(t('The ID of the content entity this workspace is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Content entity revision ID')) + ->setDescription(t('The revision ID of the content entity this workspace is for.')) + ->setRequired(TRUE) + ->setRevisionable(TRUE); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function getOwner() { + return $this->get('uid')->entity; + } + + /** + * {@inheritdoc} + */ + public function getOwnerId() { + return $this->getEntityKey('uid'); + } + + /** + * {@inheritdoc} + */ + public function setOwnerId($uid) { + $this->set('uid', $uid); + return $this; + } + + /** + * {@inheritdoc} + */ + public function setOwner(UserInterface $account) { + $this->set('uid', $account->id()); + return $this; + } + + /** + * Creates or updates an entity's workspace whilst saving that entity. + * + * @param \Drupal\content_moderation\Entity\ContentWorkspace $content_workspace + * The content moderation entity content entity to create or save. + * + * @internal + * This method should only be called as a result of saving the related + * content entity. + */ + public static function updateOrCreateFromEntity(ContentWorkspace $content_workspace) { + $content_workspace->realSave(); + } + + /** + * Default value callback for the 'uid' base field definition. + * + * @see \Drupal\content_moderation\Entity\ContentWorkspace::baseFieldDefinitions() + * + * @return array + * An array of default values. + */ + public static function getCurrentUserId() { + return array(\Drupal::currentUser()->id()); + } + + /** + * {@inheritdoc} + */ + public function save() { + $related_entity = \Drupal::entityTypeManager() + ->getStorage($this->content_entity_type_id->value) + ->loadRevision($this->content_entity_revision_id->value); + if ($related_entity instanceof TranslatableInterface) { + $related_entity = $related_entity->getTranslation($this->activeLangcode); + } + $related_entity->workspace->target_id = $this->workspace->target_id; + return $related_entity->save(); + } + + /** + * Saves an entity permanently. + * + * When saving existing entities, the entity is assumed to be complete, + * partial updates of entities are not supported. + * + * @return int + * Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * In case of failures an exception is thrown. + */ + protected function realSave() { + return parent::save(); + } + +} diff --git a/core/modules/workspace/src/Entity/ContentWorkspaceInterface.php b/core/modules/workspace/src/Entity/ContentWorkspaceInterface.php new file mode 100644 index 0000000000..08a30f656a --- /dev/null +++ b/core/modules/workspace/src/Entity/ContentWorkspaceInterface.php @@ -0,0 +1,16 @@ +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(), + '#description' => $this->t("Label for the Workspace."), + '#required' => TRUE, + ]; + + $form['machine_name'] = [ + '#type' => 'machine_name', + '#title' => $this->t('Workspace ID'), + '#maxlength' => 255, + '#default_value' => $workspace->get('machine_name')->value, + '#disabled' => !$workspace->isNew(), + '#machine_name' => [ + 'exists' => '\Drupal\workspace\Entity\Workspace::load', + ], + '#element_validate' => [], + ]; + + $upstreams = []; + $upstream_manager = \Drupal::service('workspace.upstream_manager'); + $upstream_definitions = $upstream_manager->getDefinitions(); + foreach ($upstream_definitions as $upstream_definition) { + /** @var \Drupal\workspace\UpstreamInterface $instance */ + $instance = $upstream_manager->createInstance($upstream_definition['id']); + $upstreams[$instance->getPluginId()] = $instance->getLabel(); + } + $form['upstream'] = [ + '#type' => 'radios', + '#title' => $this->t('Default upstream'), + '#default_value' => $workspace->get('upstream')->value, + '#options' => $upstreams, + ]; + return parent::form($form, $form_state); + } + + /** + * {@inheritdoc} + */ + protected function getEditedFieldNames(FormStateInterface $form_state) { + return array_merge([ + 'label', + 'machine_name', + ], 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', + 'machine_name', + 'upstream' + ]; + 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; + $insert = $workspace->isNew(); + $workspace->save(); + \Drupal::service('workspace.upstream_manager')->clearCachedDefinitions(); + $info = ['%info' => $workspace->label()]; + $context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()]; + $logger = $this->logger('workspace'); + + if ($insert) { + $logger->notice('@type: added %info.', $context); + drupal_set_message($this->t('Workspace %info has been created.', $info)); + } + else { + $logger->notice('@type: updated %info.', $context); + drupal_set_message($this->t('Workspace %info has been updated.', $info)); + } + + if ($workspace->id()) { + $form_state->setValue('id', $workspace->id()); + $form_state->set('id', $workspace->id()); + $redirect = $this->currentUser()->hasPermission('administer workspaces') ? $workspace->toUrl('collection') : Url::fromRoute(''); + $form_state->setRedirectUrl($redirect); + } + else { + drupal_set_message($this->t('The workspace could not be saved.'), 'error'); + $form_state->setRebuild(); + } + } + +} diff --git a/core/modules/workspace/src/Entity/ReplicationLog.php b/core/modules/workspace/src/Entity/ReplicationLog.php new file mode 100644 index 0000000000..0af516af8c --- /dev/null +++ b/core/modules/workspace/src/Entity/ReplicationLog.php @@ -0,0 +1,140 @@ +get('history')->getValue(); + } + + /** + * {@inheritdoc} + */ + public function setHistory($history) { + $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 getSourceLastSeq() { + return $this->get('source_last_seq')->value; + } + + /** + * {@inheritdoc} + */ + public function setSourceLastSeq($source_last_seq) { + $this->set('source_last_seq', $source_last_seq); + return $this; + } + + /** + * @param $id string + * @return \Drupal\workspace\Entity\ReplicationLogInterface + */ + public static function loadOrCreate($id) { + $entities = \Drupal::entityTypeManager() + ->getStorage('replication_log') + ->loadByProperties(['uuid' => $id]); + if (!empty($entities)) { + return reset($entities); + } + else { + return static::create(['uuid' => $id]); + } + } + + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields['id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('ID')) + ->setDescription(t('The ID of the replication log entity.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $fields['uuid'] = BaseFieldDefinition::create('uuid') + ->setLabel(t('UUID')) + ->setDescription(t('The UUID of the replication log entity.')) + ->setReadOnly(TRUE); + + $fields['revision_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Revision ID')) + ->setDescription(t('The local revision ID of the replication log entity.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $fields['history'] = BaseFieldDefinition::create('replication_history') + ->setLabel(t('Replication log history')) + ->setDescription(t('The version id of the test entity.')) + ->setReadOnly(TRUE) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + + $fields['session_id'] = BaseFieldDefinition::create('uuid') + ->setLabel(t('Replication session ID')) + ->setDescription(t('The unique session ID of the last replication. Shortcut to the session_id in the last history item.')) + ->setReadOnly(TRUE); + + $fields['source_last_seq'] = BaseFieldDefinition::create('string') + ->setLabel(t('Last processed checkpoint')) + ->setDescription(t('The last processed checkpoint. Shortcut to the source_last_seq in the last history item.')) + ->setReadOnly(TRUE); + + $fields['ok'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('ok')) + ->setDescription(t('Replication status')) + ->setDefaultValue(TRUE) + ->setReadOnly(TRUE); + + return $fields; + } +} diff --git a/core/modules/workspace/src/Entity/ReplicationLogInterface.php b/core/modules/workspace/src/Entity/ReplicationLogInterface.php new file mode 100644 index 0000000000..20a39aa547 --- /dev/null +++ b/core/modules/workspace/src/Entity/ReplicationLogInterface.php @@ -0,0 +1,63 @@ +setLabel(t('Workaspace ID')) + ->setDescription(t('The workspace machine name.')) + ->setSetting('max_length', 128) + ->setRequired(TRUE) + ->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[\da-z_$()+-\/]*$/']]); + + $fields['uuid'] = BaseFieldDefinition::create('uuid') + ->setLabel(t('UUID')) + ->setDescription(t('The workspace UUID.')) + ->setReadOnly(TRUE); + + $fields['revision_id'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Revision ID')) + ->setDescription(t('The revision ID.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $fields['label'] = BaseFieldDefinition::create('string') + ->setLabel(t('Workaspace name')) + ->setDescription(t('The workspace name.')) + ->setRevisionable(TRUE) + ->setSetting('max_length', 128) + ->setRequired(TRUE); + + $fields['uid'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Owner')) + ->setDescription(t('The workspace owner.')) + ->setRevisionable(TRUE) + ->setSetting('target_type', 'user') + ->setDefaultValueCallback('Drupal\workspace\Entity\Workspace::getCurrentUserId'); + + $fields['changed'] = BaseFieldDefinition::create('changed') + ->setLabel(t('Changed')) + ->setDescription(t('The time that the workspace was last edited.')) + ->setRevisionable(TRUE); + + $fields['created'] = BaseFieldDefinition::create('created') + ->setLabel(t('Created')) + ->setDescription(t('The UNIX timestamp of when the workspace has been created.')); + + $fields['upstream'] = BaseFieldDefinition::create('string') + ->setLabel(t('Assign default target workspace')) + ->setDescription(t('The workspace to push to and pull from.')) + ->setRevisionable(TRUE) + ->setRequired(TRUE) + ->setDefaultValueCallback('workspace_active_id'); + + return $fields; + } + + /** + * {@inheritdoc} + */ + public function getUpdateSeq() { + return \Drupal::service('workspace.entity_index.sequence')->useWorkspace($this->id())->getLastSequenceId(); + } + + /** + * {@inheritdoc} + */ + public function setCreatedTime($created) { + $this->set('created', (int) $created); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getStartTime() { + return $this->get('created')->value; + } + + /** + * {@inheritdoc} + */ + public function getMachineName() { + return $this->get('machine_name')->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 array + * An array of default values. + */ + public static function getCurrentUserId() { + return [\Drupal::currentUser()->id()]; + } + +} diff --git a/core/modules/workspace/src/Entity/WorkspaceInterface.php b/core/modules/workspace/src/Entity/WorkspaceInterface.php new file mode 100644 index 0000000000..16a211a04d --- /dev/null +++ b/core/modules/workspace/src/Entity/WorkspaceInterface.php @@ -0,0 +1,45 @@ +entityTypeManager = $entity_type_manager; + $this->workspaceManager = $workspace_manager; + $this->defaultWorkspaceId = $default_workspace; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('workspace.manager'), + $container->getParameter('workspace.default') + + ); + } + + + /** + * Hook bridge; + * + * @see hook_entity_access() + * + * @param EntityInterface $entity + * @param string $operation + * @param AccountInterface $account + * + * @return AccessResult + */ + public function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) { + + // Workspaces themselves are handled by another hook. Ignore them here. + if ($entity->getEntityTypeId() == 'workspace') { + return AccessResult::neutral(); + } + + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + if ($workspace_manager->entityCanBelongToWorkspaces($entity) + && $entity->workspace->target_id != \Drupal::getContainer()->getParameter('workspace.default')) { + $active_workspace = $workspace_manager->getActiveWorkspace(); + $result = \Drupal::entityTypeManager() + ->getStorage('content_workspace') + ->getQuery() + ->allRevisions() + ->condition('content_entity_type_id', $entity->getEntityTypeId()) + ->condition('content_entity_id', $entity->id()) + ->condition('content_entity_revision_id', $entity->getRevisionId()) + ->condition('workspace', $active_workspace) + ->execute(); + if (empty($result)) { + return AccessResult::forbidden(); + } + } + + return $this->bypassAccessResult($account); + } + + /** + * Hook bridge; + * + * @see hook_entity_create_access() + * + * @param AccountInterface $account + * @param array $context + * @param $entity_bundle + * + * @return \Drupal\Core\Access\AccessResult + */ + public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) { + + // Workspaces themselves are handled by another hook. Ignore them here. + if ($entity_bundle == 'workspace') { + return AccessResult::neutral(); + } + + return $this->bypassAccessResult($account); + } + + /** + * @param AccountInterface $account + * @return AccessResult + */ + 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(TRUE); + + return AccessResult::allowedIfHasPermission($account, 'bypass_entity_access_workspace_' . $active_workspace->id()) + ->orIf( + AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id()) + ->andIf(AccessResult::allowedIfHasPermission($account, 'bypass_entity_access_own_workspace')) + ); + } + + /** + * Hook bridge; + * + * @see hook_entity_access() + * @see hook_ENTITY_TYPE_access() + * + * @param WorkspaceInterface $workspace + * @param string $operation + * @param AccountInterface $account + * + * @return AccessResult + */ + public function workspaceAccess(WorkspaceInterface $workspace, $operation, AccountInterface $account) { + + $operations = [ + 'view' => ['any' => 'view_any_workspace', 'own' => 'view_own_workspace'], + 'update' => ['any' => 'edit_any_workspace', 'own' => 'edit_own_workspace'], + 'delete' => ['any' => 'delete_any_workspace', 'own' => 'delete_own_workspace'], + ]; + + // The default workspace is always viewable, no matter what. + $result = AccessResult::allowedIf($operation == 'view' && $workspace->id() == $this->defaultWorkspaceId) + // Or if the user has permission to access any workspace at all. + ->orIf(AccessResult::allowedIfHasPermission($account, $operations[$operation]['any'])) + // Or if it's their own workspace, and they have permission to access their own workspace. + ->orIf( + AccessResult::allowedIf($workspace->getOwnerId() == $account->id()) + ->andIf(AccessResult::allowedIfHasPermission($account, $operations[$operation]['own'])) + ) + ->orIf(AccessResult::allowedIfHasPermission($account, $operation . '_workspace_' . $workspace->id())); + + return $result; + } + + /** + * Hook bridge; + * + * @see hook_create_access(); + * @see hook_ENTITY_TYPE_create_access(). + * + * @param \Drupal\Core\Session\AccountInterface $account + * @param array $context + * @param $entity_bundle + * + * @return AccessResult + */ + public function workspaceCreateAccess(AccountInterface $account, array $context, $entity_bundle) { + return AccessResult::allowedIfHasPermission($account, 'create_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->getAllWorkspaces() as $workspace) { + $perms += $this->createWorkspaceViewPermission($workspace) + + $this->createWorkspaceEditPermission($workspace) + + $this->createWorkspaceDeletePermission($workspace) + + $this->createWorkspaceBypassPermission($workspace); + } + + return $perms; + } + + /** + * Returns a list of all workspace entities in the system. + * + * @return WorkspaceInterface[] + */ + protected function getAllWorkspaces() { + return $this->entityTypeManager->getStorage('workspace')->loadMultiple(); + } + + /** + * Derives the view permission for a specific workspace. + * + * @param \Drupal\workspace\Entity\WorkspaceInterface $workspace + * The workspace from which to derive the permission. + * @return array + * A single-item array with the permission to define. + */ + protected function createWorkspaceViewPermission(WorkspaceInterface $workspace) { + $perms['view_workspace_' . $workspace->id()] = [ + 'title' => $this->t('View the %workspace workspace', ['%workspace' => $workspace->label()]), + 'description' => $this->t('View the %workspace workspace and content within it', ['%workspace' => $workspace->label()]), + ]; + + return $perms; + } + + /** + * Derives the edit permission for a specific workspace. + * + * @param \Drupal\workspace\Entity\WorkspaceInterface $workspace + * The workspace from which to derive the permission. + * @return array + * A single-item array with the permission to define. + */ + protected function createWorkspaceEditPermission(WorkspaceInterface $workspace) { + $perms['update_workspace_' . $workspace->id()] = [ + 'title' => $this->t('Edit the %workspace workspace', ['%workspace' => $workspace->label()]), + 'description' => $this->t('Edit the %workspace workspace itself', ['%workspace' => $workspace->label()]), + ]; + + return $perms; + } + + /** + * Derives the delete permission for a specific workspace. + * + * @param \Drupal\workspace\Entity\WorkspaceInterface $workspace + * The workspace from which to derive the permission. + * @return array + * A single-item array with the permission to define. + */ + protected function createWorkspaceDeletePermission(WorkspaceInterface $workspace) { + $perms['delete_workspace_' . $workspace->id()] = [ + 'title' => $this->t('Delete the %workspace workspace', ['%workspace' => $workspace->label()]), + 'description' => $this->t('View the %workspace workspace and all content within it', ['%workspace' => $workspace->label()]), + ]; + + return $perms; + } + + /** + * Derives the delete permission for a specific workspace. + * + * @param \Drupal\workspace\Entity\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/Form/DeploymentForm.php b/core/modules/workspace/src/Form/DeploymentForm.php new file mode 100644 index 0000000000..c43ed5f2fe --- /dev/null +++ b/core/modules/workspace/src/Form/DeploymentForm.php @@ -0,0 +1,66 @@ +id() == $workspace->get('upstream')->value) { + return []; + } + + $upstream_plugin = \Drupal::service('workspace.upstream_manager')->createInstance($workspace->get('upstream')->value); + $form['workspace_id'] = [ + '#type' => 'hidden', + '#value' => $workspace->id(), + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => 'Deploy to ' . $upstream_plugin->getLabel(), + ]; + + return $form; + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $workspace = Workspace::load($form_state->getValue('workspace_id')); + $upstream_manager = \Drupal::service('workspace.upstream_manager'); + try { + \Drupal::service('workspace.replication_manager')->replicate( + $upstream_manager->createInstance('workspace:' . $workspace->id()), + $upstream_manager->createInstance($workspace->get('upstream')->value) + ); + drupal_set_message('Successful deployment.'); + } + catch (\Exception $e) { + drupal_set_message('Deployment error', 'error'); + } + + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php new file mode 100644 index 0000000000..c2234c2cc9 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php @@ -0,0 +1,46 @@ + 'hidden', + '#value' => $workspace->id(), + ]; + + $form['instruction'] = [ + '#type' => 'markup', + '#prefix' => '

', + '#markup' => $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $workspace->label()]), + '#suffix' => '

', + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => 'Activate', + ]; + + $form['#title'] = $this->t('Activate workspace %label', array('%label' => $workspace->label())); + + return $form; + } + +} diff --git a/core/modules/workspace/src/Form/WorkspaceActivateFormBase.php b/core/modules/workspace/src/Form/WorkspaceActivateFormBase.php new file mode 100644 index 0000000000..dba7776af8 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceActivateFormBase.php @@ -0,0 +1,82 @@ +get('workspace.manager'), + $container->get('entity_type.manager') + ); + } + + public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager) { + $this->workspaceManager = $workspace_manager; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $id = $form_state->getValue('workspace_id'); + + // Ensure we are given an ID. + if (!$id) { + $form_state->setErrorByName('workspace_id', 'The workspace ID is required.'); + } + + // Ensure the workspace by that id exists. + /** @var WorkspaceInterface $workspace */ + $workspace = $this->entityTypeManager->getStorage('workspace')->load($id); + if (!$workspace) { + $form_state->setErrorByName('workspace_id', 'This workspace no longer exists.'); + } + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $id = $form_state->getValue('workspace_id'); + /** @var WorkspaceInterface $workspace */ + $workspace = $this->entityTypeManager->getStorage('workspace')->load($id); + + try { + $this->workspaceManager->setActiveWorkspace($workspace); + $form_state->setRedirect(''); + } + catch(\Exception $e) { + watchdog_exception('Workspace', $e); + drupal_set_message($e->getMessage(), 'error'); + } + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php new file mode 100644 index 0000000000..2ad2cafa92 --- /dev/null +++ b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php @@ -0,0 +1,58 @@ + 'hidden', + '#value' => $workspace->id(), + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => $workspace->label(), + ]; + + $active_workspace = $this->workspaceManager->getActiveWorkspace(); + if ($active_workspace === $workspace->id()) { + $form['submit']['#attributes']['class'] = ['is-active']; + } + + return $form; + } + +} diff --git a/core/modules/workspace/src/Index/SequenceIndex.php b/core/modules/workspace/src/Index/SequenceIndex.php new file mode 100644 index 0000000000..49d5bf5839 --- /dev/null +++ b/core/modules/workspace/src/Index/SequenceIndex.php @@ -0,0 +1,111 @@ +sortedSetFactory = $sorted_set_factory; + $this->workspaceManager = $workspace_manager; + } + + /** + * {@inheritdoc} + */ + public function useWorkspace($id) { + $this->workspaceId = $id; + return $this; + } + + /** + * {@inheritdoc} + */ + public function add(ContentEntityInterface $entity) { + $workspace_id = null; + $record = $this->buildRecord($entity); + if ($entity->getEntityType()->get('workspace') === FALSE) { + $workspace_id = 0; + } + $this->sortedSetStore($workspace_id)->add($record['seq'], $record); + } + + /** + * {@inheritdoc} + */ + public function getRange($start, $stop = NULL, $inclusive = TRUE) { + $range = $this->sortedSetStore()->getRange($start, $stop, $inclusive); + if (empty($range)) { + $range = $this->sortedSetStore(0)->getRange($start, $stop, $inclusive); + } + return $range; + } + + /** + * {@inheritdoc} + */ + public function getLastSequenceId() { + $max_key = $this->sortedSetStore()->getMaxKey(); + if (empty($max_key)) { + $max_key = $this->sortedSetStore(0)->getMaxKey(); + } + return $max_key; + } + + /** + * @param $workspace_id + * @return \Drupal\key_value\KeyValueStore\KeyValueStoreSortedSetInterface + */ + protected function sortedSetStore($workspace_id = null) { + if (!$workspace_id) { + $workspace_id = $this->workspaceId ?: $this->workspaceManager->getActiveWorkspace()->id(); + } + return $this->sortedSetFactory->get($this->collectionPrefix . $workspace_id); + } + + /** + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * @return array + */ + protected function buildRecord(ContentEntityInterface $entity) { + return [ + 'entity_type_id' => $entity->getEntityTypeId(), + 'entity_id' => $entity->id(), + 'entity_uuid' => $entity->uuid(), + 'revision_id' => $entity->getRevisionId(), + 'seq' => (int) (microtime(TRUE) * 1000000), + ]; + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Index/SequenceIndexInterface.php b/core/modules/workspace/src/Index/SequenceIndexInterface.php new file mode 100644 index 0000000000..1518a02beb --- /dev/null +++ b/core/modules/workspace/src/Index/SequenceIndexInterface.php @@ -0,0 +1,37 @@ +collection = $collection; + $this->serializer = $serializer; + $this->connection = $connection; + $this->table = $table; + } + + /** + * {@inheritdoc} + */ + public function add($key, $value) { + return $this->addMultiple([[$key => $value]]); + } + + /** + * {@inheritdoc} + */ + public function addMultiple(array $pairs) { + foreach ($pairs as $pair) { + foreach ($pair as $key => $value) { + $try_again = FALSE; + try { + $encoded_value = $this->serializer->encode($value); + $this->connection->merge($this->table) + ->fields([ + 'collection' => $this->collection, + 'name' => $key, + 'value' => $encoded_value, + ]) + ->condition('collection', $this->collection) + ->condition('value', $encoded_value) + ->execute(); + } + catch(\Exception $e) { + // If there was an exception, try to create the table. + if (!$try_again = $this->ensureTableExists()) { + // If the exception happened for other reason than the missing + // table, propagate the exception. + throw $e; + } + } + // Now that the table has been created, try again if necessary. + if ($try_again) { + $this->add($key, $value); + } + } + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCount() { + try { + return $this->connection->query('SELECT COUNT(*) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getRange($start, $stop = NULL) { + try { + $query = $this->connection->select($this->table, 't') + ->fields('t', ['value']) + ->orderBy('name', 'ASC') + ->condition('collection', $this->collection) + ->condition('name', $start, '>='); + + if (is_int($stop)) { + $query->condition('name', $stop, '<='); + } + + $values = []; + foreach ($query->execute() as $item) { + $values[] = $this->serializer->decode($item->value); + } + return $values; + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getMaxKey() { + try { + return $this->connection->query('SELECT MAX(name) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getMinKey() { + try { + return $this->connection->query('SELECT MIN(name) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * Checks if the table exists and creates if not. + * + * @return bool + */ + protected function ensureTableExists() { + try { + $database_schema = $this->connection->schema(); + if (!$database_schema->tableExists($this->table)) { + $database_schema->createTable($this->table, $this->schemaDefinition()); + return TRUE; + } + } + // If the table already exists, then attempting to recreate it will throw an + // exception. In this case just catch the exception and do nothing. + catch (SchemaObjectExistsException $e) { + return TRUE; + } + return FALSE; + } + + /** + * Act on an exception when the table might not have been created. + * + * If the table does not yet exist, that's fine, but if the table exists and + * something else caused the exception, then propagate it. + * + * @param \Exception $e + * The exception. + * + * @throws \Exception + */ + protected function catchException(\Exception $e) { + if ($this->connection->schema()->tableExists($this->table)) { + throw $e; + } + } + + /** + * The schema definition for the sorted key-value list storage table. + * + * @return array + */ + protected function schemaDefinition() { + return [ + 'description' => 'Sorted key-value list storage table.', + 'fields' => [ + 'collection' => [ + 'description' => 'A named collection of key and value pairs.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ], + // KEY is an SQL reserved word, so use 'name' as the key's field name. + 'name' => [ + 'description' => 'The index or score key for the value.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'big', + ], + 'value' => [ + 'description' => 'The value.', + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + ], + ], + 'indexes' => [ + 'collection_name' => ['collection', 'name'], + ], + ]; + } +} diff --git a/core/modules/workspace/src/KeyValueStore/KeyValueDatabaseSortedSetFactory.php b/core/modules/workspace/src/KeyValueStore/KeyValueDatabaseSortedSetFactory.php new file mode 100644 index 0000000000..363d5aa49d --- /dev/null +++ b/core/modules/workspace/src/KeyValueStore/KeyValueDatabaseSortedSetFactory.php @@ -0,0 +1,46 @@ +serializer = $serializer; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public function get($collection) { + return new DatabaseStorageSortedSet($collection, $this->serializer, $this->connection); + } +} diff --git a/core/modules/workspace/src/KeyValueStore/KeyValueSortedSetFactory.php b/core/modules/workspace/src/KeyValueStore/KeyValueSortedSetFactory.php new file mode 100644 index 0000000000..0fd80e681f --- /dev/null +++ b/core/modules/workspace/src/KeyValueStore/KeyValueSortedSetFactory.php @@ -0,0 +1,17 @@ + 'a'], [2 => 'b']]. + * + * @param array[] $map + * A map of keys and values to add. + * + * @return $this + */ + public function addMultiple(array $map); + + /** + * Get the highest key in a collection. + * + * @return int + * The highest key in the collection. + */ + public function getMaxKey(); + + /** + * Get the lowest key in in a collection. + * + * @return int + * The lowest key in the collection. + */ + public function getMinKey(); + + /** + * Get the number of items in a collection. + * + * @return int + * The number of items in a collection. + */ + public function getCount(); + + /** + * Get multiple items within a range of keys. + * + * @param int $start + * The first key in the range. + * @param int $stop + * The last key in the range. + * + * @return array + * An array of items within the given range. + */ + public function getRange($start, $stop = NULL); + +} diff --git a/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php new file mode 100644 index 0000000000..ab80d8cd53 --- /dev/null +++ b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php @@ -0,0 +1,26 @@ +container->getParameter('workspace.default'); + } + +} diff --git a/core/modules/workspace/src/Negotiator/ParamWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/ParamWorkspaceNegotiator.php new file mode 100644 index 0000000000..15bb410bcb --- /dev/null +++ b/core/modules/workspace/src/Negotiator/ParamWorkspaceNegotiator.php @@ -0,0 +1,26 @@ +query->get('workspace')); + } + + /** + * {@inheritdoc} + */ + public function getWorkspaceId(Request $request) { + return $request->query->get('workspace'); + } + +} diff --git a/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php new file mode 100644 index 0000000000..c2ce544b2a --- /dev/null +++ b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php @@ -0,0 +1,53 @@ +tempstore = $tempstore_factory->get('workspace.negotiator.session'); + } + + /** + * {@inheritdoc} + */ + public function applies(Request $request) { + // This negotiator only applies if the current user is authenticated, + // i.e. a session exists. + return $this->currentUser->isAuthenticated(); + } + + /** + * {@inheritdoc} + */ + public function getWorkspaceId(Request $request) { + $workspace_id = $this->tempstore->get('active_workspace_id'); + return $workspace_id ?: $this->container->getParameter('workspace.default'); + } + + /** + * {@inheritdoc} + */ + public function persist(WorkspaceInterface $workspace) { + $this->tempstore->set('active_workspace_id', $workspace->id()); + return TRUE; + } + +} diff --git a/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorBase.php b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorBase.php new file mode 100644 index 0000000000..2b11c4025d --- /dev/null +++ b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorBase.php @@ -0,0 +1,49 @@ +currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public function setWorkspaceManager(WorkspaceManagerInterface $entity_manager) { + $this->workspaceManager = $entity_manager; + } + + /** + * {@inheritdoc} + */ + public function persist(WorkspaceInterface $workspace) { + return TRUE; + } + +} diff --git a/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php new file mode 100644 index 0000000000..eb969342c5 --- /dev/null +++ b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php @@ -0,0 +1,43 @@ +entityManager = $entity_manager; + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults) { + $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults); + if ($storage = $this->entityManager->getStorage($entity_type_id)) { + $entity = $storage->loadRevision($value); + // If the entity type is translatable, ensure we return the proper + // translation object for the current context. + if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) { + $entity = $this->entityManager->getTranslationFromContext($entity, NULL, array('operation' => 'entity_upcast')); + } + return $entity; + } + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + if (!empty($definition['type']) && strpos($definition['type'], 'entity_revision:') === 0) { + $entity_type_id = substr($definition['type'], strlen('entity:')); + if (strpos($definition['type'], '{') !== FALSE) { + $entity_type_slug = substr($entity_type_id, 1, -1); + return $name != $entity_type_slug && in_array($entity_type_slug, $route->compile()->getVariables(), TRUE); + } + return $this->entityManager->hasDefinition($entity_type_id); + } + return FALSE; + } + + /** + * Determines the entity type ID given a route definition and route defaults. + * + * @param mixed $definition + * The parameter definition provided in the route options. + * @param string $name + * The name of the parameter. + * @param array $defaults + * The route defaults array. + * + * @throws \Drupal\Core\ParamConverter\ParamNotConvertedException + * Thrown when the dynamic entity type is not found in the route defaults. + * + * @return string + * The entity type ID. + */ + protected function getEntityTypeFromDefaults($definition, $name, array $defaults) { + $entity_type_id = substr($definition['type'], strlen('entity_revision:')); + + // If the entity type is dynamic, it will be pulled from the route defaults. + if (strpos($entity_type_id, '{') === 0) { + $entity_type_slug = substr($entity_type_id, 1, -1); + if (!isset($defaults[$entity_type_slug])) { + throw new ParamNotConvertedException(sprintf('The "%s" parameter was not converted because the "%s" parameter is missing', $name, $entity_type_slug)); + } + $entity_type_id = $defaults[$entity_type_slug]; + } + return $entity_type_id; + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php b/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php new file mode 100644 index 0000000000..8b87f5aca4 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php @@ -0,0 +1,73 @@ +workspaceManager = $workspace_manager; + $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('workspace.manager'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function build() { + $build = [ + // @todo the block depending on the toolbar is obscure; find a better way to generate this form + '#pre_render' => ['workspace_switcher_toolbar_pre_render'], + // This wil get filled in via pre-render. + 'workspace_forms' => [], + '#attached' => [ + 'library' => [ + 'workspace/drupal.workspace.switcher', + ], + ], + '#cache' => [ + 'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(), + 'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(), + ], + ]; + return $build; + } + +} diff --git a/core/modules/workspace/src/Plugin/Derivative/Workspace.php b/core/modules/workspace/src/Plugin/Derivative/Workspace.php new file mode 100644 index 0000000000..7cbefc0866 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Derivative/Workspace.php @@ -0,0 +1,47 @@ +workspaceStorage = $workspace_storage; + } + + /** + * @inheritDoc + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager')->getStorage('workspace') + ); + } + + public function getDerivativeDefinitions($base_plugin_definition) { + $workspaces = $this->workspaceStorage->loadMultiple(); + foreach ($workspaces as $workspace) { + $this->derivatives[$workspace->id()] = $base_plugin_definition; + $this->derivatives[$workspace->id()]['id'] = $base_plugin_definition['id'] . PluginBase::DERIVATIVE_SEPARATOR . $workspace->id(); + } + 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 0000000000..11c693b9d5 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php @@ -0,0 +1,155 @@ +setLabel(t('Write failures')) + ->setDescription(t('Number of failed document writes')) + ->setRequired(FALSE); + + $properties['docs_read'] = DataDefinition::create('integer') + ->setLabel(t('Documents read')) + ->setDescription(t('Number of documents read.')) + ->setRequired(FALSE); + + $properties['docs_written'] = DataDefinition::create('integer') + ->setLabel(t('Documents written')) + ->setDescription(t('Number of documents written.')) + ->setRequired(FALSE); + + $properties['end_last_seq'] = DataDefinition::create('integer') + ->setLabel(t('End sequence')) + ->setDescription(t('Sequence ID where the replication ended.')) + ->setRequired(FALSE); + + $properties['end_time'] = DataDefinition::create('datetime_iso8601') + ->setLabel(t('End time')) + ->setDescription(t('Date and time when replication ended.')) + ->setRequired(FALSE); + + $properties['missing_checked'] = DataDefinition::create('integer') + ->setLabel(t('Missing checked')) + ->setDescription(t('Number of missing documents checked.')) + ->setRequired(FALSE); + + $properties['missing_found'] = DataDefinition::create('integer') + ->setLabel(t('Missing found')) + ->setDescription(t('Number of missing documents found.')) + ->setRequired(FALSE); + + $properties['recorded_seq'] = DataDefinition::create('integer') + ->setLabel(t('Recorded sequence')) + ->setDescription(t('Recorded intermediate sequence.')) + ->setRequired(FALSE); + + $properties['session_id'] = DataDefinition::create('string') + ->setLabel(t('Session ID')) + ->setDescription(t('Unique session ID for the replication.')) + ->setRequired(TRUE); + + $properties['start_last_seq'] = DataDefinition::create('integer') + ->setLabel(t('Start sequence')) + ->setDescription(t('Sequence ID where the replication started.')) + ->setRequired(FALSE); + + $properties['start_time'] = DataDefinition::create('datetime_iso8601') + ->setLabel(t('Start time')) + ->setDescription(t('Date and time when replication started.')) + ->setRequired(FALSE); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + return array( + 'columns' => array( + 'doc_write_failures' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ), + 'docs_read' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ), + 'docs_written' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ), + 'end_last_seq' => array( + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + ), + 'end_time' => array( + 'type' => 'varchar', + 'length' => 50, + 'not null' => FALSE, + ), + 'missing_checked' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ), + 'missing_found' => array( + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => FALSE, + ), + 'recorded_seq' => array( + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + 'default' => 0, + ), + 'session_id' => array( + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + ), + 'start_last_seq' => array( + 'type' => 'int', + 'size' => 'big', + 'not null' => FALSE, + ), + 'start_time' => array( + 'type' => 'varchar', + 'length' => 50, + 'not null' => FALSE, + ), + ), + ); + } + +} diff --git a/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItemList.php b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItemList.php new file mode 100644 index 0000000000..6d7985b153 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItemList.php @@ -0,0 +1,7 @@ +getEntity(); + + if (!$entity->getEntityType()->isRevisionable()) { + return NULL; + } + + if ($entity->id() && $entity->getRevisionId()) { + $revisions = \Drupal::service('entity.query')->get('content_workspace') + ->condition('content_entity_type_id', $entity->getEntityTypeId()) + ->condition('content_entity_id', $entity->id()) + ->condition('content_entity_revision_id', $entity->getRevisionId()) + ->allRevisions() + ->sort('revision_id', 'DESC') + ->execute(); + + $revision_to_load = key($revisions); + if (!empty($revision_to_load)) { + /** @var \Drupal\workspace\Entity\ContentWorkspaceInterface $content_workspace */ + $content_workspace = \Drupal::entityTypeManager() + ->getStorage('content_workspace') + ->loadRevision($revision_to_load); + + // Return the correct translation. + $langcode = $entity->language()->getId(); + if (!$content_workspace->hasTranslation($langcode)) { + $content_workspace->addTranslation($langcode); + } + if ($content_workspace->language()->getId() !== $langcode) { + $content_workspace = $content_workspace->getTranslation($langcode); + } + + return $content_workspace->get('workspace')->entity; + } + } + } + + /** + * {@inheritdoc} + */ + public function get($index) { + if ($index !== 0) { + throw new \InvalidArgumentException('An entity can not have multiple workspaces at the same time.'); + } + $this->computeWorkspaceFieldItemList(); + return isset($this->list[$index]) ? $this->list[$index] : NULL; + } + + /** + * {@inheritdoc} + */ + public function getIterator() { + $this->computeWorkspaceFieldItemList(); + return parent::getIterator(); + } + + /** + * Recalculate the workspace field item list. + */ + protected function computeWorkspaceFieldItemList() { + // Compute the value of the workspace. + $index = 0; + if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) { + $workspace = $this->getWorkspace(); + // Do not store NULL values in the static cache. + if ($workspace) { + $this->list[$index] = $this->createItem($index, ['entity' => $workspace]); + } + } + } + +} diff --git a/core/modules/workspace/src/Plugin/Upstream/Workspace.php b/core/modules/workspace/src/Plugin/Upstream/Workspace.php new file mode 100644 index 0000000000..4bec2595cc --- /dev/null +++ b/core/modules/workspace/src/Plugin/Upstream/Workspace.php @@ -0,0 +1,63 @@ +workspace = $entity_type_manager->getStorage('workspace')->load($this->getDerivativeId()); + } + + /** + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * @param array $configuration + * @param string $plugin_id + * @param mixed $plugin_definition + * + * @return static + */ + 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') + ); + } + + /** + * @inheritDoc + */ + public function getLabel() { + return $this->workspace->label(); + } + +} diff --git a/core/modules/workspace/src/Replication/DefaultReplicator.php b/core/modules/workspace/src/Replication/DefaultReplicator.php new file mode 100644 index 0000000000..069ad461e9 --- /dev/null +++ b/core/modules/workspace/src/Replication/DefaultReplicator.php @@ -0,0 +1,153 @@ +workspaceManager = $workspace_manager; + $this->changesFactory = $changes_factory; + $this->entityTypeManager = $entity_type_manager; + $this->sequenceIndex = $sequence_index; + } + + /** + * @param \Drupal\workspace\UpstreamInterface $source + * @param \Drupal\workspace\UpstreamInterface $target + *q + * @return bool + */ + public function applies(UpstreamInterface $source, UpstreamInterface $target) { + list($source_plugin, $source_id) = explode(':', $source->getPluginId()); + list($target_plugin, $target_id) = explode(':', $target->getPluginId()); + if ($source_plugin == 'workspace' && $target_plugin == 'workspace' + && !empty($source_id) && !empty($target_id)) { + return TRUE; + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function replicate(UpstreamInterface $source, UpstreamInterface $target) { + list($source_plugin, $source_id) = explode(':', $source->getPluginId()); + list($target_plugin, $target_id) = explode(':', $target->getPluginId()); + $source = Workspace::load($source_id); + $target = Workspace::load($target_id); + $replication_id = \md5($source->id() . $target->id()); + $start_time = new \DateTime(); + $sessionId = \md5((\microtime(true) * 1000000)); + $replication_log = ReplicationLog::loadOrCreate($replication_id); + $current_active = $this->workspaceManager->getActiveWorkspace(TRUE); + + // Set the source as the active workspace. + $this->workspaceManager->setActiveWorkspace($source); + + // Get changes for the current workspace. + $history = $replication_log->getHistory(); + $last_seq = isset($history[0]['recorded_seq']) ? $history[0]['recorded_seq'] : 0; + $changes = $this->changesFactory->get($source)->lastSeq($last_seq)->getNormal(); + $rev_diffs = []; + foreach ($changes as $change) { + foreach ($change['changes'] as $change_item) { + $rev_diffs[$change['type']][] = $change_item['rev']; + } + } + + // Get revision diff between source and target + $content_workspace_ids = []; + foreach ($rev_diffs as $entity_type_id => $revs) { + $content_workspace_ids[$entity_type_id] = $this->entityTypeManager + ->getStorage('content_workspace') + ->getQuery() + ->allRevisions() + ->condition('content_entity_type_id', $entity_type_id) + ->condition('content_entity_revision_id', $revs, 'IN') + ->condition('workspace', $target->id()) + ->execute(); + } + foreach ($content_workspace_ids as $entity_type_id => $ids) { + foreach ($ids as $id) { + $key = array_search($id, $rev_diffs[$entity_type_id]); + if (isset($key)) { + unset($rev_diffs[$entity_type_id][$key]); + } + } + } + + $entities = []; + // Load each missing revision. + foreach ($rev_diffs as $entity_type_id => $revs) { + foreach ($revs as $rev) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->entityTypeManager + ->getStorage($entity_type_id) + ->loadRevision($rev); + $entity->isDefaultRevision(TRUE); + $entities[] = $entity; + } + } + + // Before saving set the active workspace to the target. + $this->workspaceManager->setActiveWorkspace($target); + + // Save each revision on the target workspace + foreach ($entities as $entity) { + $entity->save(); + } + + // Log + $this->workspaceManager->setActiveWorkspace($current_active); + + $replication_log->setHistory([ + 'recorded_seq' => $this->sequenceIndex->useWorkspace($source->id())->getLastSequenceId(), + 'start_time' => $start_time->format('D, d M Y H:i:s e'), + 'session_id' => $sessionId, + ]); + $replication_log->save(); + return $replication_log; + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Replication/ReplicationInterface.php b/core/modules/workspace/src/Replication/ReplicationInterface.php new file mode 100644 index 0000000000..d08a4f6958 --- /dev/null +++ b/core/modules/workspace/src/Replication/ReplicationInterface.php @@ -0,0 +1,28 @@ +replicators[$priority][] = $replicator; + } + + /** + * @param \Drupal\workspace\UpstreamInterface $source + * @param \Drupal\workspace\UpstreamInterface $target + * @return mixed + */ + public function replicate(UpstreamInterface $source, UpstreamInterface $target) { + foreach ($this->replicators as $replicators) { + foreach ($replicators as $replicator) { + if ($replicator->applies($source, $target)) { + return $replicator->replicate($source, $target); + } + } + } + } +} diff --git a/core/modules/workspace/src/UpstreamInterface.php b/core/modules/workspace/src/UpstreamInterface.php new file mode 100644 index 0000000000..71b39879f2 --- /dev/null +++ b/core/modules/workspace/src/UpstreamInterface.php @@ -0,0 +1,20 @@ +alterInfo('workspace_upstream_info'); + $this->setCacheBackend($cache_backend, 'workspace_upstream'); + } +} \ No newline at end of file diff --git a/core/modules/workspace/src/WorkspaceAccessException.php b/core/modules/workspace/src/WorkspaceAccessException.php new file mode 100644 index 0000000000..55b58e6a2f --- /dev/null +++ b/core/modules/workspace/src/WorkspaceAccessException.php @@ -0,0 +1,13 @@ +workspaceManager = $workspace_manager; + } + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t('Workspace'); + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return 'ws.' . $this->workspaceManager->getActiveWorkspace(); + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($type = NULL) { + return new CacheableMetadata(); + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/WorkspaceListBuilder.php b/core/modules/workspace/src/WorkspaceListBuilder.php new file mode 100644 index 0000000000..e4ca51be04 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceListBuilder.php @@ -0,0 +1,95 @@ +get('entity.manager')->getStorage($entity_type->id()), + $container->get('workspace.manager') + ); + } + + /** + * Constructs a new EntityListBuilder object. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * @param \Drupal\Core\Entity\EntityStorageInterface $storage + * The entity storage class. + * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager + */ + public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager) { + parent::__construct($entity_type, $storage); + $this->workspaceManager = $workspace_manager; + } + + /** + * {@inheritdoc} + */ + public function buildHeader() { + $header['label'] = t('Workspace'); + $header['uid'] = t('Owner'); + $header['status'] = t('Status'); + + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + /** @var WorkspaceInterface $entity */ + $row['label'] = $entity->label() . ' (' . $entity->getMachineName() . ')'; + $row['owner'] = $entity->getOwner()->getDisplayname(); + $active_workspace = $this->workspaceManager->getActiveWorkspace(); + $row['status'] = $active_workspace == $entity->id() ? 'Active' : 'Inactive'; + return $row + parent::buildRow($entity); + } + + /** + * {@inheritdoc} + */ + public function getDefaultOperations(EntityInterface $entity) { + /** @var WorkspaceInterface $entity */ + $operations = parent::getDefaultOperations($entity); + if (isset($operations['edit'])) { + $operations['edit']['query']['destination'] = $entity->url('collection'); + } + + $active_workspace = $this->workspaceManager->getActiveWorkspace(); + if ($entity->id() != $active_workspace) { + $operations['activate'] =[ + 'title' => $this->t('Set Active'), + 'weight' => 20, + 'url' => $entity->urlInfo('activate-form', ['query' => ['destination' => $entity->url('collection')]]), + ]; + } + + return $operations; + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/WorkspaceManager.php b/core/modules/workspace/src/WorkspaceManager.php new file mode 100644 index 0000000000..ed10159fe4 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManager.php @@ -0,0 +1,253 @@ +requestStack = $request_stack; + $this->entityTypeManager = $entity_type_manager; + $this->currentUser = $current_user; + $this->logger = $logger ?: new NullLogger(); + } + + /** + * {@inheritdoc} + */ + public function entityCanBelongToWorkspaces(EntityInterface $entity) { + return $this->entityTypeCanBelongToWorkspaces($entity->getEntityType()); + } + + /** + * {@inheritdoc} + */ + public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type) { + if (!in_array($entity_type->id(), $this->blacklist) + &&is_a($entity_type->getClass(), EntityPublishedInterface::class, TRUE) + && $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 addNegotiator(WorkspaceNegotiatorInterface $negotiator, $priority) { + $this->negotiators[$priority][] = $negotiator; + $this->sortedNegotiators = NULL; + } + + /** + * {@inheritdoc} + */ + public function load($workspace_id) { + $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); + if ($workspace instanceof WorkspaceInterface) { + return $workspace; + } + throw new EntityStorageException("Workspace not found"); + } + + /** + * {@inheritdoc} + */ + public function loadMultiple(array $workspace_ids = NULL) { + return $this->entityTypeManager->getStorage('workspace')->loadMultiple($workspace_ids); + } + + /** + * {@inheritdoc} + */ + public function loadByMachineName($machine_name) { + $workspaces = $this->entityTypeManager->getStorage('workspace')->loadByProperties(['machine_name' => $machine_name]); + return current($workspaces); + } + + /** + * {@inheritdoc} + * + * @todo {@link https://www.drupal.org/node/2600382 Access check.} + */ + public function getActiveWorkspace($object = FALSE) { + $request = $this->requestStack->getCurrentRequest(); + foreach ($this->getSortedNegotiators() as $negotiator) { + if ($negotiator->applies($request)) { + if ($workspace_id = $negotiator->getWorkspaceId($request)) { + if ($object) { + return $this->load($workspace_id); + } + else { + return $workspace_id; + } + } + } + } + } + + /** + * {@inheritdoc} + */ + public function setActiveWorkspace(WorkspaceInterface $workspace) { + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + // If the current user doesn't have access to view the workspace, they + // shouldn't be allowed to switch to it. + // @todo Could this be handled better? + if (!$workspace->access('view') && ($workspace->id() != $default_workspace_id)) { + $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->getSortedNegotiators() as $negotiator) { + if ($negotiator->applies($request)) { + $negotiator->persist($workspace); + break; + } + } + + $supported_entity_types = $this->getSupportedEntityTypes(); + foreach ($supported_entity_types as $supported_entity_type) { + $this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache(); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function updateOrCreateFromEntity(EntityInterface $entity) { + if (!$this->entityCanBelongToWorkspaces($entity)) { + return; + } + + // If the entity is not new there should be a ContentWorkspace entry for it. + if (!$entity->isNew()) { + $content_workspaces = $this->entityTypeManager + ->getStorage('content_workspace') + ->loadByProperties([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_entity_id' => $entity->id(), + ]); + + /** @var \Drupal\workspace\Entity\ContentWorkspaceInterface $content_workspace */ + $content_workspace = reset($content_workspaces); + } + + // If there was a ContentWorkspace entry create a new revision, otherwise + // create a new entity with the type and ID. + if (!empty($content_workspace) && $content_workspace instanceof ContentWorkspaceInterface) { + $content_workspace->setNewRevision(TRUE); + } + else { + $content_workspace = ContentWorkspace::create([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_entity_id' => $entity->id() + ]); + } + + // Add the revision ID, workspace, and publishing status. + $content_workspace->set('content_entity_revision_id', $entity->getRevisionId()); + $content_workspace->set('workspace', $this->getActiveWorkspace()); + $entity->initial_published ? $content_workspace->setPublished() : $content_workspace->setUnpublished(); + ContentWorkspace::updateOrCreateFromEntity($content_workspace); + } + + /** + * @return \Drupal\workspace\Negotiator\WorkspaceNegotiatorInterface[] + */ + protected function getSortedNegotiators() { + if (!isset($this->sortedNegotiators)) { + // Sort the negotiators according to priority. + krsort($this->negotiators); + // Merge nested negotiators from $this->negotiators into + // $this->sortedNegotiators. + $this->sortedNegotiators = []; + foreach ($this->negotiators as $builders) { + $this->sortedNegotiators = array_merge($this->sortedNegotiators, $builders); + } + } + return $this->sortedNegotiators; + } + +} diff --git a/core/modules/workspace/src/WorkspaceManagerInterface.php b/core/modules/workspace/src/WorkspaceManagerInterface.php new file mode 100644 index 0000000000..3e2c1a6997 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManagerInterface.php @@ -0,0 +1,82 @@ +getParameter('renderer.config'); + $renderer_config['required_cache_contexts'][] = 'workspace'; + $container->setParameter('renderer.config', $renderer_config); + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/WorkspaceTypeListBuilder.php b/core/modules/workspace/src/WorkspaceTypeListBuilder.php new file mode 100644 index 0000000000..5da8dd9910 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceTypeListBuilder.php @@ -0,0 +1,28 @@ +t('Workspace type'); + $header['id'] = $this->t('Machine name'); + return $header + parent::buildHeader(); + } + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $row['label'] = $entity->label(); + $row['id'] = $entity->id(); + + return $row + parent::buildRow($entity); + } +} \ No newline at end of file diff --git a/core/modules/workspace/templates/workspace-deployment.html.twig b/core/modules/workspace/templates/workspace-deployment.html.twig new file mode 100644 index 0000000000..ab75d5d061 --- /dev/null +++ b/core/modules/workspace/templates/workspace-deployment.html.twig @@ -0,0 +1,31 @@ +{# +/** + * @file + * Default theme implementation for deployment. + * + * @see template_preprocess_workspace_deployment() + * + * @ingroup themeable + */ +#} + +
+ + + {% for workspace in workspaces %} +
+ +
+ {% endfor %} +
+ +
+
\ No newline at end of file diff --git a/core/modules/workspace/tests/src/Functional/ReplicationTest.php b/core/modules/workspace/tests/src/Functional/ReplicationTest.php new file mode 100644 index 0000000000..308568136d --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/ReplicationTest.php @@ -0,0 +1,115 @@ +createNodeType('Test', 'test'); + + $permissions = [ + 'create_workspace', + 'view_any_workspace', + 'create test content', + 'edit own test content', + 'access administration pages', + 'administer taxonomy', + 'administer menu', + 'access content overview', + 'administer content types', + 'administer node display', + 'administer node fields', + 'administer node form display', + ]; + $test_user = $this->drupalCreateUser($permissions); + $this->drupalLogin($test_user); + + $this->setupWorkspaceSwitcherBlock(); + } + + public function testNodeReplication() { + $dev = $this->createWorkspaceThroughUI('Dev', 'dev'); + $stage = $this->getOneWorkspaceByLabel('Stage'); + $live = $this->getOneWorkspaceByLabel('Live'); + $this->switchToWorkspace($dev); + + $this->drupalGet('/node/add/test'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + $page = $session->getPage(); + $page->fillField('Title', 'Test node'); + $page->findButton(t('Save'))->click(); + $page = $session->getPage(); + $page->hasContent("Test node has been created"); + $this->drupalGet('/node/1/edit'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + $page->findButton(t('Save'))->click(); + + $this->assertEquals($dev->id(), $this->getOneEntityByLabel('node', 'Test node')->workspace->target_id); + + /** @var \Drupal\workspace\Replication\ReplicationManager $replicator */ + $replicator = \Drupal::service('workspace.replication_manager'); + /** @var \Drupal\workspace\UpstreamManager $upstream */ + $upstream = \Drupal::service('workspace.upstream_manager'); + $replicator->replicate( + $upstream->createInstance('workspace:' . $dev->id()), + $upstream->createInstance('workspace:' . $stage->id()) + ); + + $this->switchToWorkspace($stage); + $this->assertEquals($stage->id(), $this->getOneEntityByLabel('node', 'Test node')->workspace->target_id); + + $this->drupalGet('/node/add/test'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + $page = $session->getPage(); + $page->fillField('Title', 'Test stage node'); + $page->findButton(t('Save'))->click(); + $page = $session->getPage(); + $page->hasContent("Test stage node has been created"); + + $this->assertEquals($stage->id(), $this->getOneEntityByLabel('node', 'Test stage node')->workspace->target_id); + + $replicator->replicate( + $upstream->createInstance('workspace:' . $stage->id()), + $upstream->createInstance('workspace:' . $live->id()) + ); + + $this->switchToWorkspace($live); + $this->assertEquals($live->id(), $this->getOneEntityByLabel('node', 'Test node')->workspace->target_id); + $this->assertEquals($live->id(), $this->getOneEntityByLabel('node', 'Test stage node')->workspace->target_id); + } +} \ No newline at end of file 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 0000000000..0c9888c7e5 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php @@ -0,0 +1,138 @@ +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); + + $vanilla_node = $this->createNodeThroughUI('Vanilla node', 'test'); + $this->assertEquals('live', $vanilla_node->workspace->target_id); + + $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'); + $this->assertEquals($bears->id(), $ditka_bears_node->workspace->entity->id()); + $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_workspace_' . $bears->id(), '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'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + + $lombardi_bears_node = $this->createNodeThroughUI('Lombardi Bears node', 'test'); + $this->assertEquals($bears->id(), $lombardi_bears_node->workspace->entity->id()); + $lombardi_bears_node_id = $lombardi_bears_node->id(); + + $this->drupalLogin($ditka); + $this->switchToWorkspace($bears); + + $this->drupalGet('/node/' . $lombardi_bears_node_id . '/edit'); + $session = $this->getSession(); + $this->assertEquals(403, $session->getStatusCode()); + + // Create a new user that should NOT be able to edit anything in the Bears workspace. + $belichick = $this->drupalCreateUser(array_merge($permissions, ['view_workspace_' . $bears->id()])); + $this->drupalLogin($belichick); + $this->switchToWorkspace($bears); + + $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit'); + $session = $this->getSession(); + $this->assertEquals(403, $session->getStatusCode()); + + } + + /** + * 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); + + $vanilla_node = $this->createNodeThroughUI('Vanilla node', 'test'); + $this->assertEquals('live', $vanilla_node->workspace->target_id); + + $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'); + $this->assertEquals($bears->id(), $ditka_bears_node->workspace->entity->id()); + $ditka_bears_node_id = $ditka_bears_node->id(); + + // Editing both nodes should be possible. + + $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + + // Create a new user that should be able to edit anything in the Bears workspace. + $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view_workspace_' . $bears->id()])); + $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'); + $session = $this->getSession(); + $this->assertEquals(403, $session->getStatusCode()); + + } + + +} diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceEntityTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceEntityTest.php new file mode 100644 index 0000000000..826de5162f --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceEntityTest.php @@ -0,0 +1,157 @@ +createNodeType('Test', 'test'); + $this->setupWorkspaceSwitcherBlock(); + + $buster = $this->drupalCreateUser(array_merge($permissions, ['view own unpublished content', 'create test content', 'edit own test content'])); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($buster); + + $workspaces = [ + 'live' => $this->getOneWorkspaceByLabel('Live'), + 'stage' => $this->getOneWorkspaceByLabel('Stage'), + 'dev' => $this->createWorkspaceThroughUI('Dev', 'dev'), + ]; + $default = \Drupal::getContainer()->getParameter('workspace.default'); + $this->switchToWorkspace($workspaces[$initial_workspace]); + + $workspace_manager = \Drupal::service('workspace.manager'); + $this->assertEquals($initial_workspace, $workspace_manager->getActiveWorkspace()); + + $vanilla_node = $this->createNodeThroughUI('Vanilla node', 'test'); + $this->assertEquals($initial_workspace, $vanilla_node->workspace->target_id); + + $this->drupalGet('/node'); + $this->assertSession()->pageTextContains('Vanilla node'); + $this->drupalGet('/node/' . $vanilla_node->id()); + $this->assertSession()->pageTextContains('Vanilla node'); + + $strawberry_node = $this->createNodeThroughUI('Strawberry node', 'test', FALSE); + $this->assertEquals($initial_workspace, $strawberry_node->workspace->target_id); + + $this->drupalGet('/node'); + $this->assertSession()->pageTextNotContains('Strawberry node'); + $this->drupalGet('/node/' . $strawberry_node->id()); + $this->assertSession()->pageTextContains('Strawberry node'); + + $chocolate_node = $this->createNodeThroughUI('Chocolate node', 'test', FALSE); + $this->assertEquals($initial_workspace, $chocolate_node->workspace->target_id); + + $this->drupalGet('/node'); + $this->assertSession()->pageTextNotContains('Chocolate node'); + $this->drupalGet('/node/' . $chocolate_node->id()); + $this->assertSession()->pageTextContains('Chocolate node'); + + $this->drupalPostForm('/node/' . $chocolate_node->id() . '/edit', [ + 'title[0][value]' => 'Mint node' + ], t('Save and publish')); + + $this->drupalGet('/node'); + $this->assertSession()->pageTextContains('Mint node'); + $this->drupalGet('/node/' . $chocolate_node->id()); + $this->assertSession()->pageTextContains('Mint node'); + + foreach ($workspaces as $workspace_id => $workspace) { + if ($workspace_id != $initial_workspace) { + $this->switchToWorkspace($workspace); + + if ($initial_workspace == $default || $workspace_id == $default) { + // When the node started on the default workspace, or the current + // workspace is default, entity queries should return the correct + // revision. + $node_list = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties(['title' => $vanilla_node->label()]); + $this->assertSame($vanilla_node->getRevisionId(), reset($node_list)->getRevisionId()); + } + else { + // When the node was created on a non-default workspace and the + // current workspace is not the default, entity queries should return + // nothing. + $node_list = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadByProperties(['title' => $vanilla_node->label()]); + $this->assertSame(FALSE, reset($node_list)); + } + + // Entity load and load_multiple should always return the default + // revision. + $node_load = \Drupal::entityTypeManager() + ->getStorage('node') + ->load($vanilla_node->id()); + $this->assertSame($vanilla_node->getRevisionId(), $node_load->getRevisionId()); + $node = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadUnchanged($vanilla_node->id()); + $this->assertSame($vanilla_node->getRevisionId(), $node->getRevisionId()); + + if ($initial_workspace == $default) { + // Then the node was created on the default workspace it should + // appear via the UI on all other workspaces. + $this->drupalGet('/node'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Vanilla node'); + $this->drupalGet('/node/' . $vanilla_node->id()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Vanilla node'); + } + else { + // When the node was not created on the default it should only not + // appear via the UI. + $this->drupalGet('/node'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextNotContains('Vanilla node'); + $this->drupalGet('/node/' . $vanilla_node->id()); + $this->assertSession()->statusCodeEquals(403); + $this->assertSession()->pageTextNotContains('Vanilla node'); + } + } + } + } + + public function nodeEntityTestCases() { + return [ + ['live'], + ['stage'], + ['dev'], + ]; + } +} diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php new file mode 100644 index 0000000000..3704a2aba3 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php @@ -0,0 +1,99 @@ +drupalCreateUser($permissions); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($editor1); + + $this->createWorkspaceThroughUI('Bears', 'bears'); + $bears = $this->getOneWorkspaceByLabel('Bears'); + + // Now login as a different user with permission to edit that workspace, + // specifically. + + $editor2 = $this->drupalCreateUser(array_merge($permissions, ['update_workspace_' . $bears->id()])); + + $this->drupalLogin($editor2); + $session = $this->getSession(); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + } + + /** + * Verifies that a user can view a specific workspace. + */ + public function testViewIndividualWorkspace() { + $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'); + $bears = $this->getOneWorkspaceByLabel('Bears'); + + // Now login as a different user and create a workspace. + + $editor2 = $this->drupalCreateUser(array_merge($permissions, ['view_workspace_' . $bears->id()])); + + $this->drupalLogin($editor2); + $session = $this->getSession(); + + $this->createWorkspaceThroughUI('Packers', 'packers'); + + $packers = $this->getOneWorkspaceByLabel('Packers'); + + // Load the activate form for the Bears workspace. It should work, because + // the user has the permission specific to that workspace. + $this->drupalGet("admin/structure/workspace/{$bears->id()}/activate"); + $this->assertEquals(200, $session->getStatusCode()); + + // But editor 1 cannot view the Packers workspace. + + $this->drupalLogin($editor1); + $this->drupalGet("admin/structure/workspace/{$packers->id()}/activate"); + $this->assertEquals(403, $session->getStatusCode()); + } + +} 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 0000000000..0b52aca923 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php @@ -0,0 +1,168 @@ +drupalCreateUser([ + 'access administration pages', + 'administer site configuration', + 'create_workspace', + ]); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($editor); + $session = $this->getSession(); + + $this->drupalGet('/admin/structure/workspace/add'); + + $this->assertEquals(200, $session->getStatusCode()); + + $page = $session->getPage(); + $page->fillField('label', 'Bears'); + $page->fillField('machine_name', 'bears'); + $page->findButton(t('Save'))->click(); + + $session->getPage()->hasContent('Bears (bears)'); + + // Now edit that same workspace; We shouldn't be able to do so, since + // we don't have edit permissions. + + /** @var EntityTypeManagerInterface $etm */ + $etm = \Drupal::service('entity_type.manager'); + /** @var WorkspaceInterface $bears */ + $entity_list = $etm->getStorage('workspace')->loadByProperties(['label' => 'Bears']); + $bears = current($entity_list); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(403, $session->getStatusCode()); + + // @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 = $this->getOneWorkspaceByLabel('Bears'); + + $session = $this->getSession(); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + + $page = $session->getPage(); + $page->fillField('label', 'Bears again'); + $page->fillField('machine_name', 'bears'); + $page->findButton(t('Save'))->click(); + $session->getPage()->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); + $session = $this->getSession(); + + $this->createWorkspaceThroughUI('Packers', 'packers'); + + $packers = $this->getOneWorkspaceByLabel('Packers'); + + $this->drupalGet("/admin/structure/workspace/{$packers->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(403, $session->getStatusCode()); + } + + /** + * 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 = $this->getOneWorkspaceByLabel('Bears'); + + $session = $this->getSession(); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + + $page = $session->getPage(); + $page->fillField('label', 'Bears again'); + $page->fillField('machine_name', 'bears'); + $page->findButton(t('Save'))->click(); + $session->getPage()->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); + $session = $this->getSession(); + + $this->createWorkspaceThroughUI('Packers', 'packers'); + + $packers = $this->getOneWorkspaceByLabel('Packers'); + + $this->drupalGet("/admin/structure/workspace/{$packers->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + + $this->drupalGet("/admin/structure/workspace/{$bears->id()}/edit"); + $this->assertEquals(200, $session->getStatusCode()); + } +} 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 0000000000..0132452eaa --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php @@ -0,0 +1,55 @@ +setupWorkspaceSwitcherBlock(); + + $mayer = $this->drupalCreateUser($permissions); + $this->drupalLogin($mayer); + + $vultures = $this->createWorkspaceThroughUI('Vultures', 'vultures'); + $this->switchToWorkspace($vultures); + + $gravity = $this->createWorkspaceThroughUI('Gravity', 'gravity'); + + $this->drupalGet('/admin/structure/workspace/' . $gravity->id() . '/activate'); + + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + $page = $session->getPage(); + $page->findButton(t('Activate'))->click(); + + $session->getPage()->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 0000000000..d34f31eb87 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php @@ -0,0 +1,42 @@ +drupalCreateUser($permissions); + $this->drupalLogin($editor1); + + // Test a valid workspace name + $this->createWorkspaceThroughUI('Workspace 1', 'a0_$()+-/'); + + // Test and invaid workspace name + $this->drupalGet('/admin/structure/workspace/add'); + $session = $this->getSession(); + $this->assertEquals(200, $session->getStatusCode()); + $page = $session->getPage(); + $page->fillField('label', 'workspace2'); + $page->fillField('machine_name', 'A!"£%^&*{}#~@?'); + $page->findButton(t('Save'))->click(); + $session->getPage()->hasContent("This value is not valid"); + } +} \ No newline at end of file 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 0000000000..b686db495f --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php @@ -0,0 +1,211 @@ +getOneEntityByLabel('workspace', $label); + } + + /** + * Loads a single entity by its label. + * + * The UI approach to creating an entity doesn't make it easy to know what + * the ID is, so this lets us make paths for an entity after it's created. + * + * @param string $type + * The type of entity to load. + * @param $label + * The label of the entity to load. + * @return WorkspaceInterface + */ + protected function getOneEntityByLabel($type, $label) { + /** @var EntityTypeManagerInterface $etm */ + $etm = \Drupal::service('entity_type.manager'); + + $property = $etm->getDefinition($type)->getKey('label'); + + $entity_list = $etm->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 $machine_name + * The machine name of the workspace to create. + * + * @return WorkspaceInterface + * The workspace that was just created. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function createWorkspaceThroughUI($label, $machine_name) { + $this->drupalGet('/admin/structure/workspace/add'); + + $session = $this->getSession(); + $this->assertSession()->statusCodeEquals(200); + + $page = $session->getPage(); + $page->fillField('label', $label); + $page->fillField('machine_name', $machine_name); + $page->findButton(t('Save'))->click(); + + $session->getPage()->hasContent("$label ($machine_name)"); + + return $this->getOneWorkspaceByLabel($label); + } + + /** + * 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 WorkspaceInterface $workspace + * The workspace to set active. + */ + protected function switchToWorkspace(WorkspaceInterface $workspace) { + // Switch the test runner's context to the specified workspace. + \Drupal::service('workspace.manager')->setActiveWorkspace($workspace); + + // Switch the system under test to the specified workspace. + $this->getSession()->getPage()->findButton($workspace->label())->click(); + + // 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 + * @param string $bundle + * + * @return \Drupal\workspace\Entity\WorkspaceInterface + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function createNodeThroughUI($label, $bundle, $publish = TRUE) { + $this->drupalGet('/node/add/' . $bundle); + + $session = $this->getSession(); + $this->assertSession()->statusCodeEquals(200); + + $page = $session->getPage(); + $page->fillField('Title', $label); + if ($publish) { + $page->findButton(t('Save and publish'))->click(); + } + else { + $page->findButton(t('Save as unpublished'))->click(); + } + + $session->getPage()->hasContent("{$label} has been created"); + + return $this->getOneEntityByLabel('node', $label); + } + + /** + * Returns a pointer to the specified workspace. + * + * @todo Replace this with a common method in the module somewhere. + * + * @param \Drupal\workspace\Entity\WorkspaceInterface $workspace + * The workspace for which we want a pointer. + * @return WorkspacePointerInterface + * The pointer to the provided workspace. + */ + protected function getPointerToWorkspace(WorkspaceInterface $workspace) { + /** @var EntityTypeManagerInterface $etm */ + $etm = \Drupal::service('entity_type.manager'); + + $pointers = $etm->getStorage('workspace_pointer') + ->loadByProperties(['workspace_pointer' => $workspace->id()]); + $pointer = reset($pointers); + return $pointer; + } + + /** + * 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/WorkspaceViewTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php new file mode 100644 index 0000000000..1eaecb14b5 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php @@ -0,0 +1,104 @@ +drupalCreateUser($permissions); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($editor1); + + $this->createWorkspaceThroughUI('Bears', 'bears'); + + $bears = $this->getOneWorkspaceByLabel('Bears'); + + // Now login as a different user and create a workspace. + + $editor2 = $this->drupalCreateUser($permissions); + + $this->drupalLogin($editor2); + $session = $this->getSession(); + + $this->createWorkspaceThroughUI('Packers', 'packers'); + + $packers = $this->getOneWorkspaceByLabel('Packers'); + + // Load the activate form for the Bears workspace. It should fail because + // the workspace belongs to someone else. + $this->drupalGet("admin/structure/workspace/{$bears->id()}/activate"); + $this->assertEquals(403, $session->getStatusCode()); + + // But editor 2 should be able to activate the Packers workspace. + $this->drupalGet("admin/structure/workspace/{$packers->id()}/activate"); + $this->assertEquals(200, $session->getStatusCode()); + } + + /** + * Verifies that a user can view any workspace. + */ + public function testViewAnyWorkspace() { + $permissions = [ + 'access administration pages', + 'administer site configuration', + 'create_workspace', + 'edit_own_workspace', + 'view_any_workspace', + ]; + + $editor1 = $this->drupalCreateUser($permissions); + + // Login as a limited-access user and create a workspace. + $this->drupalLogin($editor1); + + $this->createWorkspaceThroughUI('Bears', 'bears'); + + $bears = $this->getOneWorkspaceByLabel('Bears'); + + // Now login as a different user and create a workspace. + + $editor2 = $this->drupalCreateUser($permissions); + + $this->drupalLogin($editor2); + $session = $this->getSession(); + + $this->createWorkspaceThroughUI('Packers', 'packers'); + + $packers = $this->getOneWorkspaceByLabel('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/structure/workspace/{$bears->id()}/activate"); + $this->assertEquals(200, $session->getStatusCode()); + + // But editor 2 should be able to activate the Packers workspace. + $this->drupalGet("admin/structure/workspace/{$packers->id()}/activate"); + $this->assertEquals(200, $session->getStatusCode()); + } +} diff --git a/core/modules/workspace/tests/src/Kernel/DatabaseStorageSortedSetTest.php b/core/modules/workspace/tests/src/Kernel/DatabaseStorageSortedSetTest.php new file mode 100644 index 0000000000..d0fe7cef2b --- /dev/null +++ b/core/modules/workspace/tests/src/Kernel/DatabaseStorageSortedSetTest.php @@ -0,0 +1,143 @@ +collection = $this->randomMachineName(); + $this->serializer = \Drupal::service('serialization.phpserialize'); + $this->connection = \Drupal::service('database'); + $this->store = \Drupal::service('workspace.keyvalue.sorted_set')->get($this->collection); + } + + /** + * Helper method to assert key value pairs. + * + * @param $expected_pairs array + * Array of expected key value pairs. + */ + public function assertPairs(array $expected_pairs) { + $result = $this->connection->select('key_value_sorted', 't') + ->fields('t', ['name', 'value']) + ->condition('collection', $this->collection) + ->condition('name', array_keys($expected_pairs), 'IN') + ->execute() + ->fetchAllAssoc('name'); + + $expected_count = count($expected_pairs); + $this->assertCount($expected_count, $result, "Query affected $expected_count records."); + foreach ($expected_pairs as $key => $value) { + $this->assertSame($value, $this->serializer->decode($result[$key]->value), "Key $key have value $value"); + } + } + + /** + * Helper method to assert the number of records. + * + * @param $expected int + * Expected number of records. + * @param null $message string + * The message to display. + */ + public function assertRecords($expected, $message = NULL) { + $count = $this->store->getCount(); + $this->assertEquals($expected, $count, $message ? $message : "There are $expected records."); + } + + /** + * Helper method to generate a key based on microtime(). + * + * @return integer + * A key based on microtime(). + */ + public function newKey() { + return (int) (microtime(TRUE) * 1000000); + } + + /** + * Tests getting and setting of sorted key value sets. + */ + public function testCalls() { + $key0 = $this->newKey(); + $value0 = $this->randomMachineName(); + $this->store->add($key0, $value0); + $this->assertPairs([$key0 => $value0]); + + $key1 = $this->newKey(); + $value1 = $this->randomMachineName(); + $this->store->add($key1, $value1); + $this->assertPairs([$key1 => $value1]); + + // Ensure it works to add sets with the same key. + $key2 = $this->newKey(); + $value2 = $this->randomMachineName(); + $value3 = $this->randomMachineName(); + $value4 = $this->randomMachineName(); + $this->store->addMultiple([ + [$key2 => $value2], + [$key2 => $value3], + [$key2 => $value4], + ]); + + $this->assertRecords(5, 'Correct number of records in the collection.'); + + $value = $this->store->getRange($key1, $key2); + $this->assertSame([$value1, $value2, $value3, $value4], $value); + + $value = $this->store->getRange($key1); + $this->assertSame([$value1, $value2, $value3, $value4], $value); + + $new1 = $this->newKey(); + $this->store->add($new1, $value1); + + $value = $this->store->getRange($new1, $new1); + $this->assertSame([$value1], $value, 'Value was successfully updated.'); + $this->assertRecords(5, 'Correct number of records in the collection after value update.'); + + $value = $this->store->getRange($key1, $key1); + $this->assertSame([], $value, 'Non-existing range returned empty array.'); + + $max_key = $this->store->getMaxKey(); + $this->assertEquals($new1, $max_key, 'The getMaxKey method returned correct key.'); + + $min_key = $this->store->getMinKey(); + $this->assertEquals($key0, $min_key, 'The getMinKey method returned correct key.'); + } + +} diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml new file mode 100644 index 0000000000..ec543d1d15 --- /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) +dependencies: + - user + - serialization diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install new file mode 100644 index 0000000000..dfd134eaea --- /dev/null +++ b/core/modules/workspace/workspace.install @@ -0,0 +1,17 @@ + 'live', 'label' => 'Live']); + $live->save(); + + /** @var \Drupal\workspace\Entity\WorkspaceInterface $stage */ + $stage = Workspace::create(['machine_name' => 'stage', 'label' => 'Stage']); + $stage->set('upstream', 'workspace:' . $live->id()); + $stage->save(); +} diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml new file mode 100644 index 0000000000..d5e3a80ecb --- /dev/null +++ b/core/modules/workspace/workspace.libraries.yml @@ -0,0 +1,17 @@ +drupal.workspace.toolbar: + version: VERSION + css: + theme: + css/workspace.toolbar.css: {} + +drupal.workspace.admin: + version: VERSION + css: + theme: + css/workspace.admin.css: {} + +drupal.workspace.switcher: + version: VERSION + css: + theme: + css/workspace.switcher.css: {} diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml new file mode 100644 index 0000000000..a7efd0bf52 --- /dev/null +++ b/core/modules/workspace/workspace.links.menu.yml @@ -0,0 +1,10 @@ +entity.workspace.collection: + title: 'Workspaces' + parent: system.admin_structure + description: 'Create and manage workspaces.' + route_name: entity.workspace.collection +workspace.deployment: + title: 'Deployment' + route_name: workspace.deployment + description: 'Deploy workspaces.' + parent: system.admin_config_workflow diff --git a/core/modules/workspace/workspace.links.task.yml b/core/modules/workspace/workspace.links.task.yml new file mode 100644 index 0000000000..b664c0103c --- /dev/null +++ b/core/modules/workspace/workspace.links.task.yml @@ -0,0 +1,4 @@ +entity.workspace.collection: + title: 'Workspaces' + route_name: entity.workspace.collection + base_route: entity.workspace.collection diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module new file mode 100644 index 0000000000..601076c941 --- /dev/null +++ b/core/modules/workspace/workspace.module @@ -0,0 +1,390 @@ +' . 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_base_field_info(). + */ +function workspace_entity_base_field_info(EntityTypeInterface $entity_type) { + if ($entity_type->isRevisionable() && !in_array($entity_type->id(), ['content_workspace', 'workspace'])) { + return ['workspace' => BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Workspace')) + ->setDescription(t('The Workspace of this piece of content.')) + ->setComputed(TRUE) + ->setClass(WorkspaceFieldItemList::class) + ->setSetting('target_type', 'workspace') + ->setTranslatable(TRUE)]; + } +} + +/** + * Implements hook_query_TAG_alter(). + */ +function workspace_query_entity_query_alter(AlterableInterface $query) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + $active_workspace = $workspace_manager->getActiveWorkspace(); + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + if ($active_workspace == $default_workspace_id) { + return; + } + + $entity_type = \Drupal::entityTypeManager()->getDefinition($query->getMetaData('entity_type')); + if (!empty($entity_type) && $workspace_manager->entityTypeCanBelongToWorkspaces($entity_type)) { + $entity_type_id = $entity_type->id(); + $entity_type_id_key = $entity_type->getKey('id'); + $query->leftJoin('content_workspace_field_revision', 'cwfr', 'base_table.' . $entity_type_id_key . ' = cwfr.content_entity_id'); + $query->condition('cwfr.content_entity_type_id', $entity_type_id); + $query->condition('cwfr.workspace', [$active_workspace, $default_workspace_id], 'IN'); + } +} + +/** + * Implements hook_views_query_alter(). + */ +function workspace_views_query_alter(ViewExecutable $view, QueryPluginBase $query) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + $active_workspace = $workspace_manager->getActiveWorkspace(); + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + if ($active_workspace == $default_workspace_id) { + return; + } + + $entity_type = $view->getBaseEntityType(); + if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity_type)) { + return; + } + + $configuration = [ + 'table' => 'content_workspace_field_revision', + 'field' => 'content_entity_revision_id', + 'left_table' => $entity_type->getDataTable(), + 'left_field' => $entity_type->getKey('revision'), + 'operator' => '=', + ]; + /** @var \Drupal\views\Plugin\views\join\JoinPluginBase $join */ + $join = Views::pluginManager('join') + ->createInstance('standard', $configuration); + /** @var \Drupal\views\Plugin\views\query\Sql $query */ + $query->addRelationship('cwrf', $join, 'content_workspace_field_revision'); + $query->addWhere(0, 'cwrf.workspace', [$active_workspace, $default_workspace_id], 'IN'); + foreach ($query->where as $where_id => $where) { + foreach ($where['conditions'] as $condition_id => $condition) { + if ($condition['field'] == $entity_type->getDataTable() . '.' . $entity_type->getKey('published')) { + //$query->where[$where_id]['conditions'][$condition_id]['field'] = 'cwrf.published'; + $value = $query->where[$where_id]['conditions'][$condition_id]['value']; + $query->setWhereGroup('OR', 'published'); + $query->addWhere('published', 'cwrf.published', $value); + $query->addWhere('published', $entity_type->getDataTable() . '.' . $entity_type->getKey('published'), $value); + unset($query->where[$where_id]['conditions'][$condition_id]); + } + } + } +} + + +/** + * Implements hook_entity_load(). + */ +function workspace_entity_load(array &$entities, $entity_type_id) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + if (!$workspace_manager->entityTypeCanBelongToWorkspaces(\Drupal::entityTypeManager()->getDefinition($entity_type_id))) { + return; + } + + $active_workspace = $workspace_manager->getActiveWorkspace(); + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + if ($active_workspace == $default_workspace_id) { + return; + } + + $keys = array_keys($entities); + $results = \Drupal::entityTypeManager() + ->getStorage('content_workspace') + ->getQuery() + ->condition('content_entity_type_id', $entity_type_id) + ->condition('content_entity_id', $keys, 'IN') + ->condition('workspace', [$active_workspace, $default_workspace_id], 'IN') + ->execute(); + foreach ($results as $revision_id => $entity_id) { + /** @var \Drupal\workspace\Entity\ContentWorkspaceInterface $content_workspace */ + $content_workspace = \Drupal::entityTypeManager() + ->getStorage('content_workspace') + ->loadRevision($revision_id); + $entity = $entities[$content_workspace->get('content_entity_id')->value]; + if ($content_workspace->get('content_entity_revision_id')->value != $entity->getRevisionId()) { + $new_entity = \Drupal::entityTypeManager() + ->getStorage($entity_type_id) + ->loadRevision($content_workspace->get('content_entity_revision_id')->value); + $entities[$entity->id()] = $new_entity; + } + $content_workspace->isPublished() ? $entities[$entity->id()]->setPublished() : $entities[$entity->id()]->setUnpublished(); + } +} + +/** + * Implements hook_element_info_alter(). + */ +function workspace_element_info_alter(array &$types) { + foreach ($types as &$type) { + if (!isset($type['#pre_render'])) { + $type['#pre_render'] = array(); + } + $type['#pre_render'][] = 'workspace_element_pre_render'; + } +} + +/** + * Element pre-render callback. + */ +function workspace_element_pre_render($element) { + if (isset($element['#cache'])) { + if (!isset($element['#cache']['contexts'])) { + $element['#cache']['contexts'] = []; + } + $element['#cache']['contexts'] = Cache::mergeContexts( + $element['#cache']['contexts'], ['workspace'] + ); + } + return $element; +} + +/** + * Implements hook_entity_presave(). + */ +function workspace_entity_presave(EntityInterface $entity) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + + // Only modify the entity if the active workspace isn't the default, and + // and the entity can belong to a workspace. + if (!empty($workspace_manager->getActiveWorkspace()) + && $workspace_manager->entityCanBelongToWorkspaces($entity)) { + + if (!$entity->isNew()) { + $original_workspace_id = $entity->original->workspace->target_id; + $workspace_id = $entity->workspace->target_id; + /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */ + if ($original_workspace_id == $workspace_id) { + // Force a new revision is the entity is not new and the workspace + // is the same as the previous revision. + $entity->setNewRevision(TRUE); + } + } + + // The publishing status can be stored in a property for safe keeping + $entity->initial_published = $entity->isPublished(); + + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + if ($default_workspace_id != $workspace_manager->getActiveWorkspace()) { + // As this is the non-default workspace only new entity revisions should be + // made default. + $entity->isNew() || ($entity->original->workspace->target_id != $default_workspace_id) ? $entity->isDefaultRevision(TRUE) : $entity->isDefaultRevision(FALSE); + + // All entities in the non-default workspace get unpublished. + $entity->setUnpublished(); + } + } +} + +/** + * Implements hook_entity_insert(). + */ +function workspace_entity_insert(Drupal\Core\Entity\EntityInterface $entity) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + $workspace_manager->updateOrCreateFromEntity($entity); + if ($workspace_manager->entityCanBelongToWorkspaces($entity)) { + \Drupal::service('workspace.index.sequence') + ->useWorkspace($workspace_manager->getActiveWorkspace()) + ->add($entity); + } +} + +/** + * Implements hook_entity_update(). + */ +function workspace_entity_update(Drupal\Core\Entity\EntityInterface $entity) { + /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspace.manager'); + $workspace_manager->updateOrCreateFromEntity($entity); + if ($workspace_manager->entityCanBelongToWorkspaces($entity)) { + \Drupal::service('workspace.index.sequence') + ->useWorkspace($workspace_manager->getActiveWorkspace()) + ->add($entity); + } +} + +/** + * Default value callback for 'upstream' base field definition. + * + * @return array + */ +function workspace_active_id() { + return 'workspace:' . \Drupal::service('workspace.manager')->getActiveWorkspace(); +} + +/** + * Implements hook_toolbar(). + * + * @see \Drupal\workspace\Toolbar + */ +function workspace_toolbar() { + $items = []; + + /** @var \Drupal\workspace\Entity\WorkspaceInterface $active_workspace */ + $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(TRUE); + + $items['workspace_switcher'] = [ + // Include the toolbar_tab_wrapper to style the link like a toolbar tab. + // Exclude the theme wrapper if custom styling is desired. + '#type' => 'toolbar_item', + '#weight' => 125, + '#wrapper_attributes' => [ + 'class' => ['workspace-toolbar-tab'], + ], + '#attached' => [ + 'library' => [ + 'workspace/drupal.workspace.toolbar', + ], + ], + ]; + + $items['deploy'] = [ + '#type' => 'toolbar_item', + '#weight' => 130, + '#wrapper_attributes' => [ + 'class' => ['workspace-deploy-toolbar-tab'], + ], + 'tab' => [ + \Drupal::formBuilder()->getForm(\Drupal\workspace\Form\DeploymentForm::class, $active_workspace), + ], + ]; + + $items['workspace_switcher']['tab'] = [ + '#type' => 'link', + '#title' => t('@active', ['@active' => $active_workspace->label()]), + '#url' => Url::fromRoute('entity.workspace.collection'), + '#attributes' => [ + 'title' => t('Switch workspaces'), + 'class' => ['toolbar-icon', 'toolbar-icon-workspace'], + ], + ]; + + $create_link = [ + '#type' => 'link', + '#title' => t('Add workspace'), + '#url' => Url::fromRoute('entity.workspace.add_form'), + '#options' => ['attributes' => ['class' => ['add-workspace']]], + ]; + + $items['workspace_switcher']['tray'] = [ + '#heading' => t('Switch to workspace'), + '#pre_render' => ['workspace_switcher_toolbar_pre_render'], + // This wil get filled in via pre-render. + 'workspace_forms' => [], + 'create_link' => $create_link, + '#cache' => [ + 'contexts' => \Drupal::entityTypeManager()->getDefinition('workspace')->getListCacheContexts(), + 'tags' => \Drupal::entityTypeManager()->getDefinition('workspace')->getListCacheTags(), + ], + '#attributes' => [ + 'class' => ['toolbar-menu'], + ], + ]; + + return $items; +} + +/** + * Prerender callback; Adds the workspace switcher forms to the render array. + * + * @param array $element + * + * @return array + * The modified $element. + */ +function workspace_switcher_toolbar_pre_render(array $element) { + /** @var \Drupal\workspace\Entity\WorkspaceInterface $workspace */ + foreach (\Drupal::entityTypeManager()->getStorage('workspace')->loadMultiple() as $workspace) { + if ($workspace->access('view', \Drupal::currentUser())) { + $element['workspace_forms']['workspace_' . $workspace->getMachineName()] = \Drupal::formBuilder()->getForm(WorkspaceSwitcherForm::class, $workspace); + } + } + + return $element; +} + +/** + * 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) + ->entityAccess($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_ENTITY_TYPE_access(). + * + * @see \Drupal\workspace\EntityAccess + */ +function workspace_workspace_access(EntityInterface $entity, $operation, AccountInterface $account) { + return \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityAccess::class) + ->workspaceAccess($entity, $operation, $account); +} + +/** + * Implements hook_ENTITY_TYPE_create_access(). + * + * @see \Drupal\workspace\EntityAccess + */ +function workspace_workspace_create_access(AccountInterface $account, array $context, $entity_bundle) { + return \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityAccess::class) + ->workspaceCreateAccess($account, $context, $entity_bundle); +} diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml new file mode 100644 index 0000000000..475d7e541e --- /dev/null +++ b/core/modules/workspace/workspace.permissions.yml @@ -0,0 +1,42 @@ +create_workspace: + title: Create a new workspace + +view_own_workspace: + title: View own workspace + description: View a workspace owned by the user. + +view_any_workspace: + title: View any workspace + description: View any workspace, regardless of ownership. + +edit_own_workspace: + title: Edit own workspace + description: Make changes to workspaces owned by the user. + +edit_any_workspace: + title: Edit any workspace + description: Make changes to any workspace, regardless of ownership. + +delete_own_workspace: + title: Delete own workspace + description: Delete a workspace owned by the user and all content revisions within it. + +delete_any_workspace: + title: Delete any workspace + description: Delete a workspace and all content revisions within it, regardless of ownership. + +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 + +view_revision_trees: + title: View revision trees + description: View the revision tree for any entities. + +update any workspace from upstream: + title: Update any workspace from upstream + description: Update any workspace with the latest changes from its upstream workspace. + +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 0000000000..da89e77d46 --- /dev/null +++ b/core/modules/workspace/workspace.routing.yml @@ -0,0 +1,25 @@ +entity.workspace.collection: + path: '/admin/structure/workspace' + defaults: + _title: 'Workspaces' + _entity_list: 'workspace' + requirements: + _permission: 'administer workspaces+edit_any_workspace' + +entity.workspace.activate_form: + path: '/admin/structure/workspace/{workspace}/activate' + defaults: + _title: 'Activate Workspace' + _form: '\Drupal\workspace\Form\WorkspaceActivateForm' + options: + _admin_route: TRUE + requirements: + _workspace_view: 'TRUE' + +workspace.deployment: + path: '/admin/config/workflow/deployment' + defaults: + _title: 'Workspace deployment' + _controller: '\Drupal\workspace\Controller\DeploymentController::workspaces' + requirements: + _permission: 'administer workspaces' \ No newline at end of file diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml new file mode 100644 index 0000000000..1143559ee0 --- /dev/null +++ b/core/modules/workspace/workspace.services.yml @@ -0,0 +1,80 @@ +parameters: + workspace.default: live + workspace.factory.keyvalue.sorted_set: + default: keyvalue.sorted_set.database + +services: + workspace.paramconverter.entity_revision: + class: Drupal\workspace\ParamConverter\EntityRevisionConverter + arguments: ['@entity.manager'] + tags: + - { name: paramconverter, priority: 30 } + access_check.workspace_view: + class: Drupal\workspace\Access\WorkspaceViewCheck + tags: + - { name: access_check, applies_to: _workspace_view } + workspace.manager: + class: Drupal\workspace\WorkspaceManager + arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@logger.channel.workspace'] + tags: + - { name: service_collector, tag: workspace_negotiator, call: addNegotiator } + workspace.index.sequence: + class: Drupal\workspace\Index\SequenceIndex + arguments: ['@workspace.keyvalue.sorted_set', '@workspace.manager'] + workspace.changes_factory: + class: Drupal\workspace\Changes\ChangesFactory + arguments: ['@entity_type.manager', '@workspace.index.sequence'] + workspace.keyvalue.sorted_set: + class: Drupal\workspace\KeyValueStore\KeyValueSortedSetFactory + arguments: ['@service_container', '%workspace.factory.keyvalue.sorted_set%'] + workspace.keyvalue.sorted_set.database: + class: Drupal\workspace\KeyValueStore\KeyValueDatabaseSortedSetFactory + arguments: ['@serialization.phpserialize', '@database'] + workspace.upstream_manager: + class: Drupal\workspace\UpstreamManager + parent: default_plugin_manager + + workspace.replication_manager: + class: Drupal\workspace\Replication\ReplicationManager + tags: + - { name: service_collector, tag: workspace_replicator, call: addReplicator } + workspace.default_replicator: + class: Drupal\workspace\Replication\DefaultReplicator + arguments: ['@workspace.manager', '@workspace.changes_factory', '@entity_type.manager', '@workspace.index.sequence'] + tags: + - { name: workspace_replicator, priority: 0 } + + workspace.negotiator.default: + class: Drupal\workspace\Negotiator\DefaultWorkspaceNegotiator + calls: + - [setContainer, ['@service_container']] + - [setCurrentUser, ['@current_user']] + - [setWorkspaceManager, ['@workspace.manager']] + tags: + - { name: workspace_negotiator, priority: 0 } + workspace.negotiator.session: + class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator + arguments: ['@user.private_tempstore'] + calls: + - [setContainer, ['@service_container']] + - [setCurrentUser, ['@current_user']] + - [setWorkspaceManager, ['@workspace.manager']] + tags: + - { name: workspace_negotiator, priority: 100 } + workspace.negotiator.param: + class: Drupal\workspace\Negotiator\ParamWorkspaceNegotiator + calls: + - [setContainer, ['@service_container']] + - [setCurrentUser, ['@current_user']] + - [setWorkspaceManager, ['@workspace.manager']] + tags: + - { name: workspace_negotiator, priority: 200 } + + cache_context.workspace: + class: Drupal\workspace\WorkspaceCacheContext + arguments: ['@workspace.manager'] + tags: + - { name: cache.context } + logger.channel.workspace: + parent: logger.channel_base + arguments: ['cron']