diff --git a/core/composer.json b/core/composer.json index ba488e5..f8bd203 100644 --- a/core/composer.json +++ b/core/composer.json @@ -140,7 +140,8 @@ "drupal/update": "self.version", "drupal/user": "self.version", "drupal/views": "self.version", - "drupal/views_ui": "self.version" + "drupal/views_ui": "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 baeacb9..004c5b5 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/workspace/config/install/workspace.type.basic.yml b/core/modules/workspace/config/install/workspace.type.basic.yml new file mode 100644 index 0000000..3d9c2cb --- /dev/null +++ b/core/modules/workspace/config/install/workspace.type.basic.yml @@ -0,0 +1,5 @@ +langcode: en +status: true +dependencies: { } +id: basic +label: Basic diff --git a/core/modules/workspace/config/schema/workspace.schema.yml b/core/modules/workspace/config/schema/workspace.schema.yml new file mode 100644 index 0000000..0049f5b --- /dev/null +++ b/core/modules/workspace/config/schema/workspace.schema.yml @@ -0,0 +1,10 @@ +workspace.type.*: + type: config_entity + label: 'Workspace type' + mapping: + id: + type: string + label: 'Machine name' + label: + type: label + label: 'Label' diff --git a/core/modules/workspace/css/workspace.admin.css b/core/modules/workspace/css/workspace.admin.css new file mode 100644 index 0000000..31b757d --- /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 0000000..a833a6b --- /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 0000000..9b82323 --- /dev/null +++ b/core/modules/workspace/css/workspace.toolbar.css @@ -0,0 +1,67 @@ +/** + * @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{ + float: right; +} + +.toolbar #toolbar-item-workspace-switcher-tray input[type="submit"] { + display: block; + background: transparent; + border: 0; + border-radius: 0; + margin: 0; + padding: 1em 1.3333em; + font-weight: normal; + font-size: 1em; + line-height: 1; + color: #565656; +} + +.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 0000000..c114dea --- /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 0000000..0d893f3 --- /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 0000000..61bba58 --- /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 0000000..584ec8c --- /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 0000000..8d45be1 --- /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/Controller/WorkspaceController.php b/core/modules/workspace/src/Controller/WorkspaceController.php new file mode 100644 index 0000000..0b993c9 --- /dev/null +++ b/core/modules/workspace/src/Controller/WorkspaceController.php @@ -0,0 +1,41 @@ +addForm($type); + } + if (count($types) === 0) { + return array( + '#markup' => $this->t('You have not created any Workspace types yet. Go to the Workspace type creation page to add a new Workspace type.', [ + ':url' => Url::fromRoute('entity.workspace_type.add_form')->toString(), + ]), + ); + } + + return array('#theme' => 'workspace_add_list', '#content' => $types); + + } + + public function addForm(WorkspaceTypeInterface $workspace_type) { + $workspace = Workspace::create([ + 'type' => $workspace_type->id() + ]); + return $this->entityFormBuilder()->getForm($workspace); + } + + public function getAddFormTitle(WorkspaceTypeInterface $workspace_type) { + return $this->t('Add %type workspace', array('%type' => $workspace_type->label())); + } +} diff --git a/core/modules/workspace/src/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/DefaultWorkspaceNegotiator.php new file mode 100644 index 0000000..bc65453 --- /dev/null +++ b/core/modules/workspace/src/DefaultWorkspaceNegotiator.php @@ -0,0 +1,23 @@ +container->getParameter('workspace.default'); + } + +} diff --git a/core/modules/workspace/src/Entity/ContentWorkspace.php b/core/modules/workspace/src/Entity/ContentWorkspace.php new file mode 100644 index 0000000..8db613f --- /dev/null +++ b/core/modules/workspace/src/Entity/ContentWorkspace.php @@ -0,0 +1,173 @@ +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 0000000..4527a08 --- /dev/null +++ b/core/modules/workspace/src/Entity/ContentWorkspaceInterface.php @@ -0,0 +1,15 @@ +entity; + + if ($this->operation == 'edit') { + $form['#title'] = $this->t('Edit workspace %label', array('%label' => $workspace->label())); + } + $form['label'] = array( + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $workspace->label(), + '#description' => $this->t("Label for the Workspace."), + '#required' => TRUE, + ); + + $form['machine_name'] = array( + '#type' => 'machine_name', + '#title' => $this->t('Workspace ID'), + '#maxlength' => 255, + '#default_value' => $workspace->get('machine_name')->value, + '#machine_name' => array( + 'exists' => '\Drupal\workspace\Entity\Workspace::load', + ), + '#element_validate' => array(), + ); + + return parent::form($form, $form_state, $workspace);; + } + + /** + * {@inheritdoc} + */ + protected function getEditedFieldNames(FormStateInterface $form_state) { + return array_merge(array( + '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 = array( + 'label', + 'machine_name' + ); + 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(); + $info = ['%info' => $workspace->label()]; + $context = array('@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') : $workspace->toUrl('canonical'); + $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/Form/WorkspaceTypeDeleteForm.php b/core/modules/workspace/src/Entity/Form/WorkspaceTypeDeleteForm.php new file mode 100644 index 0000000..74b0080 --- /dev/null +++ b/core/modules/workspace/src/Entity/Form/WorkspaceTypeDeleteForm.php @@ -0,0 +1,56 @@ +queryFactory = $query_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.query') + ); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $workspaces = $this->queryFactory->get('workspace')->condition('type', $this->entity->id())->execute(); + if (!empty($workspaces)) { + $caption = '

' . $this->formatPlural(count($workspaces), '%label is used by 1 workspace on your site. You can not remove this workspace type until you have removed all of the %label workspaces.', '%label is used by @count workspaces on your site. You may not remove %label until you have removed all of the %label custom workspaces.', array('%label' => $this->entity->label())) . '

'; + $form['description'] = array('#markup' => $caption); + return $form; + } + else { + return parent::buildForm($form, $form_state); + } + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Entity/Form/WorkspaceTypeForm.php b/core/modules/workspace/src/Entity/Form/WorkspaceTypeForm.php new file mode 100644 index 0000000..dc8a49c --- /dev/null +++ b/core/modules/workspace/src/Entity/Form/WorkspaceTypeForm.php @@ -0,0 +1,64 @@ +entity; + $form['label'] = array( + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $workspace_type->label(), + '#description' => $this->t("Label for the Workspace type."), + '#required' => TRUE, + ); + + $form['id'] = array( + '#type' => 'machine_name', + '#default_value' => $workspace_type->id(), + '#machine_name' => array( + 'exists' => '\Drupal\workspace\Entity\WorkspaceType::load', + ), + '#disabled' => !$workspace_type->isNew(), + ); + + return $this->protectBundleIdElement($form); + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $workspace_type = $this->entity; + $status = $workspace_type->save(); + + switch ($status) { + case SAVED_NEW: + drupal_set_message($this->t('Created the %label Workspace type.', [ + '%label' => $workspace_type->label(), + ])); + break; + + default: + drupal_set_message($this->t('Saved the %label Workspace type.', [ + '%label' => $workspace_type->label(), + ])); + } + $form_state->setRedirectUrl($workspace_type->urlInfo('collection')); + } + +} \ No newline at end of file diff --git a/core/modules/workspace/src/Entity/Workspace.php b/core/modules/workspace/src/Entity/Workspace.php new file mode 100644 index 0000000..c0bbd4f --- /dev/null +++ b/core/modules/workspace/src/Entity/Workspace.php @@ -0,0 +1,205 @@ +setLabel(t('Workspace ID')) + ->setDescription(t('The workspace ID.')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $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['machine_name'] = BaseFieldDefinition::create('string') + ->setLabel(t('Workaspace ID')) + ->setDescription(t('The workspace machine name.')) + ->setRevisionable(TRUE) + ->setSetting('max_length', 128) + ->setRequired(TRUE) + ->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[\da-z_$()+-\/]*$/']]); + + $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['type'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Type')) + ->setDescription(t('The workspace type.')) + ->setSetting('target_type', 'workspace_type') + ->setReadOnly(TRUE); + + $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('entity_reference') + ->setLabel(t('Assign default target workspace')) + ->setDescription(t('The workspace to push to and pull from.')) + ->setRevisionable(TRUE) + ->setRequired(TRUE) + ->setSetting('target_type', 'workspace') + ->setDefaultValueCallback('workspace_active_id') + ->setDisplayOptions('form', [ + 'type' => 'options_buttons', + 'weight' => 0 + ]) + ->setDisplayConfigurable('form', TRUE); + + 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 0000000..16a211a --- /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; + } + + /** + * 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(); + } + + 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(); + + 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/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php new file mode 100644 index 0000000..c2234c2 --- /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 0000000..da242cb --- /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'); + } + } + +} diff --git a/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php new file mode 100644 index 0000000..047c16b --- /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->id() === $workspace->id()) { + $form['submit']['#attributes']['class'] = ['is-active']; + } + + return $form; + } + +} diff --git a/core/modules/workspace/src/ParamConverter/EntityRevisionConverter.php b/core/modules/workspace/src/ParamConverter/EntityRevisionConverter.php new file mode 100644 index 0000000..b118a0f --- /dev/null +++ b/core/modules/workspace/src/ParamConverter/EntityRevisionConverter.php @@ -0,0 +1,95 @@ +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 0000000..47b7f6e --- /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.toolbar:preRenderWorkspaceSwitcherForms'], + // 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/Field/WorkspaceFieldItemList.php b/core/modules/workspace/src/Plugin/Field/WorkspaceFieldItemList.php new file mode 100644 index 0000000..d8862a3 --- /dev/null +++ b/core/modules/workspace/src/Plugin/Field/WorkspaceFieldItemList.php @@ -0,0 +1,93 @@ +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(); + + if ($revision_to_load = key($revisions)) { + /** @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; + } + } + return \Drupal::getContainer()->getParameter('workspace.default'); + } + + /** + * {@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/SessionWorkspaceNegotiator.php b/core/modules/workspace/src/SessionWorkspaceNegotiator.php new file mode 100644 index 0000000..5a150c5 --- /dev/null +++ b/core/modules/workspace/src/SessionWorkspaceNegotiator.php @@ -0,0 +1,50 @@ +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/Toolbar.php b/core/modules/workspace/src/Toolbar.php new file mode 100644 index 0000000..86197da --- /dev/null +++ b/core/modules/workspace/src/Toolbar.php @@ -0,0 +1,148 @@ +entityTypeManager = $entity_type_manager; + $this->workspaceManager = $workspace_manager; + $this->formBuilder = $form_builder; + $this->currentUser = $current_user; + } + + /** + * Hook bridge; Responds to hook_toolbar(). + * + * @see hook_toolbar(). + */ + public function toolbar() { + $items = []; + + $active = $this->workspaceManager->getActiveWorkspace(); + + $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['workspace_switcher']['tab'] = [ + '#type' => 'link', + '#title' => $this->t('@active', ['@active' => $active->label()]), + '#url' => Url::fromRoute('entity.workspace.collection'), + '#attributes' => [ + 'title' => $this->t('Switch workspaces'), + 'class' => ['toolbar-icon', 'toolbar-icon-workspace'], + ], + ]; + + $create_link = [ + '#type' => 'link', + '#title' => t('Add workspace'), + '#url' => Url::fromRoute('entity.workspace.add'), + '#options' => array('attributes' => array('class' => array('add-workspace'))), + ]; + + $items['workspace_switcher']['tray'] = [ + '#heading' => $this->t('Switch to workspace'), + '#pre_render' => ['workspace.toolbar:preRenderWorkspaceSwitcherForms'], + // This wil get filled in via pre-render. + 'workspace_forms' => [], + 'create_link' => $create_link, + '#cache' => [ + 'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(), + 'tags' => $this->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. + */ + public function preRenderWorkspaceSwitcherForms(array $element) { + foreach ($this->allWorkspaces() as $workspace) { + $element['workspace_forms']['workspace_' . $workspace->getMachineName()] = $this->formBuilder->getForm(WorkspaceSwitcherForm::class, $workspace); + } + + return $element; + } + + /** + * Returns a list of all defined and accessible workspaces. + * + * Note: This assumes that the total number of workspaces on the site is + * very small. If it's actually large this method will have memory issues. + * + * @return WorkspaceInterface[] + */ + protected function allWorkspaces() { + return array_filter($this->entityTypeManager->getStorage('workspace')->loadMultiple(), function(WorkspaceInterface $workspace) { + return $workspace->access('view', $this->currentUser); + }); + } +} diff --git a/core/modules/workspace/src/WorkspaceAccessException.php b/core/modules/workspace/src/WorkspaceAccessException.php new file mode 100644 index 0000000..55b58e6 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceAccessException.php @@ -0,0 +1,13 @@ +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\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['type'] = t('Type'); + $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(); + /** @var WorkspaceTypeInterface $type */ + $type = $entity->get('type')->first()->entity; + $row['type'] = $type ? $type->label() : ''; + $active_workspace = $this->workspaceManager->getActiveWorkspace()->id(); + $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()->id(); + if ($entity->id() != $active_workspace) { + $operations['activate'] = array( + '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 0000000..3e51c8e --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManager.php @@ -0,0 +1,185 @@ +requestStack = $request_stack; + $this->entityManager = $entity_manager; + $this->currentUser = $current_user; + $this->logger = $logger ?: new NullLogger(); + } + + /** + * {@inheritdoc} + */ + public function addNegotiator(WorkspaceNegotiatorInterface $negotiator, $priority) { + $this->negotiators[$priority][] = $negotiator; + $this->sortedNegotiators = NULL; + } + + /** + * {@inheritdoc} + */ + public function load($workspace_id) { + return $this->entityManager->getStorage('workspace')->load($workspace_id); + } + + /** + * {@inheritdoc} + */ + public function loadMultiple(array $workspace_ids = NULL) { + return $this->entityManager->getStorage('workspace')->loadMultiple($workspace_ids); + } + + /** + * {@inheritdoc} + */ + public function loadByMachineName($machine_name) { + $workspaces = $this->entityManager->getStorage('workspace')->loadByProperties(['machine_name' => $machine_name]); + return current($workspaces); + } + + /** + * {@inheritdoc} + * + * @todo {@link https://www.drupal.org/node/2600382 Access check.} + */ + public function getActiveWorkspace() { + $request = $this->requestStack->getCurrentRequest(); + foreach ($this->getSortedNegotiators() as $negotiator) { + if ($negotiator->applies($request)) { + if ($workspace_id = $negotiator->getWorkspaceId($request)) { + if ($workspace = $this->load($workspace_id)) { + return $workspace; + } + } + } + } + } + + /** + * {@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; + } + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function updateOrCreateFromEntity(EntityInterface $entity) { + if (!$entity->getEntityType()->isRevisionable() || in_array($entity->getEntityTypeId(), ['content_workspace', 'workspace'])) { + return; + } + + $content_workspaces = $this->entityManager + ->getStorage('content_workspace') + ->loadByProperties([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_entity_id' => $entity->id(), + ]); + + $content_workspace = reset($content_workspaces); + if (!$content_workspace instanceof ContentWorkspaceInterface) { + $content_workspace = ContentWorkspace::create([ + 'content_entity_type_id' => $entity->getEntityTypeId(), + 'content_entity_id' => $entity->id() + ]); + } + else { + $content_workspace->setNewRevision(TRUE); + } + + $content_workspace->set('content_entity_revision_id', $entity->getRevisionId()); + $content_workspace->set('workspace', $this->getActiveWorkspace()); + ContentWorkspace::updateOrCreateFromEntity($content_workspace); + + } + + /** + * @return \Drupal\workspace\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 = array(); + 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 0000000..56d4da5 --- /dev/null +++ b/core/modules/workspace/src/WorkspaceManagerInterface.php @@ -0,0 +1,55 @@ +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/WorkspaceNegotiatorInterface.php b/core/modules/workspace/src/WorkspaceNegotiatorInterface.php new file mode 100644 index 0000000..f9ba1fb --- /dev/null +++ b/core/modules/workspace/src/WorkspaceNegotiatorInterface.php @@ -0,0 +1,39 @@ +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-add-list.html.twig b/core/modules/workspace/templates/workspace-add-list.html.twig new file mode 100644 index 0000000..6a0ff29 --- /dev/null +++ b/core/modules/workspace/templates/workspace-add-list.html.twig @@ -0,0 +1,20 @@ +{# +/** + * @file + * Default theme implementation to present a list of custom Workspace types. + * + * Available variables: + * - types: A collection of all the available custom Workspace types. + * Each Workspace type contains the following: + * - link: A link to add a Workspace of this type. + * + * @ingroup themeable + */ +#} + diff --git a/core/modules/workspace/templates/workspace-rev.html.twig b/core/modules/workspace/templates/workspace-rev.html.twig new file mode 100644 index 0000000..c273518 --- /dev/null +++ b/core/modules/workspace/templates/workspace-rev.html.twig @@ -0,0 +1,29 @@ +{# +/** + * @file + * Default theme implementation for revisions. + * + * Available variables: + * - entity_type_id: The entity type ID. + * - entity_id: The entity ID. + * - revision_id: The entity revision ID. + * - uuid: The entity UUID. + * - rev: The revision token. + * - status: The status of the revision. + * - open_rev: Whether the revision is open or not. + * - conflict: Whether the revision is a conflict or not. + * - default: Whether the revision is the defaukt one or not. + * + * - title: The revision token linked to a view of the revision. + * + * @see template_preprocess_workspace_rev() + * + * @ingroup themeable + */ +#} + +
+

{{ title }}

+
+ {% trans %}Status: {{ status }}{% endtrans %} +
\ 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 0000000..781aa32 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php @@ -0,0 +1,155 @@ +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(1, $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()); + + $bears_vanilla_node = $this->getOneEntityByLabel('node', 'Vanilla node'); + $this->drupalGet('/node/' . $bears_vanilla_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()); + + $this->drupalGet('/node/' . $bears_vanilla_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(1, $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()); + + $bears_vanilla_node = $this->getOneEntityByLabel('node', 'Vanilla node'); + $this->drupalGet('/node/' . $bears_vanilla_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()); + + $this->drupalGet('/node/' . $bears_vanilla_node->id() . '/edit'); + $session = $this->getSession(); + $this->assertEquals(403, $session->getStatusCode()); + } + + +} 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 0000000..3704a2a --- /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 0000000..0b52aca --- /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 0000000..0132452 --- /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 0000000..d34f31e --- /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 0000000..e427fa6 --- /dev/null +++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php @@ -0,0 +1,207 @@ +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'); + + /** @var WorkspaceInterface $bears */ + $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) { + $this->drupalGet('/node/add/' . $bundle); + + $session = $this->getSession(); + $this->assertSession()->statusCodeEquals(200); + + $page = $session->getPage(); + $page->fillField('Title', $label); + $page->findButton(t('Save'))->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 0000000..1eaecb1 --- /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/workspace.info.yml b/core/modules/workspace/workspace.info.yml new file mode 100644 index 0000000..27e086e --- /dev/null +++ b/core/modules/workspace/workspace.info.yml @@ -0,0 +1,8 @@ +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 diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install new file mode 100644 index 0000000..e8ad30d --- /dev/null +++ b/core/modules/workspace/workspace.install @@ -0,0 +1,22 @@ + 'live', 'label' => 'Live', 'type' => 'basic']); + $live->save(); + + $default_workspace_id = \Drupal::getContainer()->getParameter('workspace.default'); + /** @var \Drupal\workspace\Entity\WorkspaceInterface $stage */ + $stage = Workspace::create(['machine_name' => 'stage', 'label' => 'Stage', 'type' => 'basic']); + $stage->set('upstream', $default_workspace_id); + $stage->save(); + + // allow workspace entity route alterations + \Drupal::service('entity_type.manager')->clearCachedDefinitions(); + \Drupal::service('router.builder')->rebuild(); +} diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml new file mode 100644 index 0000000..e1edc2a --- /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: {} \ No newline at end of file diff --git a/core/modules/workspace/workspace.links.action.yml b/core/modules/workspace/workspace.links.action.yml new file mode 100644 index 0000000..4bd599b --- /dev/null +++ b/core/modules/workspace/workspace.links.action.yml @@ -0,0 +1,10 @@ +entity.workspace.add: + route_name: entity.workspace.add + title: 'Add workspace' + appears_on: + - entity.workspace.collection +entity.workspace_type.add_form: + route_name: 'entity.workspace_type.add_form' + title: 'Add Workspace type' + appears_on: + - entity.workspace_type.collection diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml new file mode 100644 index 0000000..fc925cf --- /dev/null +++ b/core/modules/workspace/workspace.links.menu.yml @@ -0,0 +1,5 @@ +entity.workspace.collection: + title: 'Workspaces' + parent: system.admin_structure + description: 'Create and manage workspaces.' + route_name: entity.workspace.collection diff --git a/core/modules/workspace/workspace.links.task.yml b/core/modules/workspace/workspace.links.task.yml new file mode 100644 index 0000000..084b0a3 --- /dev/null +++ b/core/modules/workspace/workspace.links.task.yml @@ -0,0 +1,17 @@ +entity.workspace.collection: + title: 'Workspaces' + route_name: entity.workspace.collection + base_route: entity.workspace.collection +entity.workspace_type.collection: + title: 'Types' + route_name: entity.workspace_type.collection + base_route: entity.workspace.collection + +entity.workspace.edit_form: + route_name: entity.workspace.edit_form + base_route: entity.workspace.canonical + title: Edit +entity.workspace_type.edit_form: + title: 'Edit' + route_name: entity.workspace_type.edit_form + base_route: entity.workspace_type.edit_form diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module new file mode 100644 index 0000000..bd387e2 --- /dev/null +++ b/core/modules/workspace/workspace.module @@ -0,0 +1,164 @@ +' . 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_entity_insert(). + */ +function workspace_entity_insert(EntityInterface $entity) { + \Drupal::service('workspace.manager')->updateOrCreateFromEntity($entity); +} + +/** + * Implements hook_entity_update(). + */ +function workspace_entity_update(EntityInterface $entity) { + \Drupal::service('workspace.manager')->updateOrCreateFromEntity($entity); +} + +/** + * Default value callback for 'upstream' base field definition. + * + * @return array + */ +function workspace_active_id() { + /** @var \Drupal\workspace\Entity\Workspace $active_workspace */ + $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(); + if ($active_workspace instanceof WorkspaceInterface) { + return [$active_workspace->id()]; + } +} + +/** + * Implements hook_toolbar(). + */ +function workspace_toolbar() { + return \Drupal::service('workspace.toolbar')->toolbar(); +} + +/** + * Implements hook_entity_access(). + */ +function workspace_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { + return \Drupal::service('workspace.entity_access')->entityAccess($entity, $operation, $account); +} + +/** + * Implements hook_entity_create_access(). + */ +function workspace_entity_create_access(AccountInterface $account, array $context, $entity_bundle) { + return \Drupal::service('workspace.entity_access')->entityCreateAccess($account, $context, $entity_bundle); +} + +/** + * Implements hook_ENTITY_TYPE_access(). + */ +function workspace_workspace_access(EntityInterface $entity, $operation, AccountInterface $account) { + return \Drupal::service('workspace.entity_access')->workspaceAccess($entity, $operation, $account); +} + +/** + * Implements hook_ENTITY_TYPE_create_access(). + */ +function workspace_workspace_create_access(AccountInterface $account, array $context, $entity_bundle) { + return \Drupal::service('workspace.entity_access')->workspaceCreateAccess($account, $context, $entity_bundle); +} + + +/** + * Implements hook_theme(). + * + * @param $existing + * @param $type + * @param $theme + * @param $path + * @return array + */ +function workspace_theme($existing, $type, $theme, $path) { + return [ + 'workspace_add_list' => [ + 'variables' => ['content' => NULL], + ], + 'workspace_rev' => [ + 'render element' => 'elements', + ], + ]; +} + +/** + * Implements hook_preprocess_HOOK + */ +function workspace_preprocess_workspace_add_list(&$variables) { + if (!empty($variables['content'])) { + foreach ($variables['content'] as $type) { + $variables['types'][$type->id()]['label'] = $type->label(); + $options = array('query' => \Drupal::request()->query->all()); + $variables['types'][$type->id()]['url'] = Url::fromRoute('entity.workspace.add_form', array('workspace_type' => $type->id()), $options); + } + } +} + +/** + * Prepares variables for revision templates. + */ +function workspace_preprocess_workspace_rev(&$variables) { + $uuid = $variables['elements']['#uuid']; + $rev = $variables['elements']['#rev']; + $rev_info = array_merge( + \Drupal::service('workspace.entity_index.rev')->get("$uuid:$rev"), + $variables['elements']['#rev_info'] + ); + + $variables = array_merge($variables, $rev_info); + + list($i) = explode('-', $rev); + // Apart from the index length, we want 7 characters plus dash and ellipsis. + $length = strlen($i) + 9; + $title = Unicode::truncate($rev, $length, FALSE, TRUE); + + if (!empty($rev_info['revision_id'])) { + $entity_revision = \Drupal::entityTypeManager()->getStorage($rev_info['entity_type_id'])->loadRevision($rev_info['revision_id']); + $variables['title'] = Link::fromTextAndUrl($title, $entity_revision->toUrl('revision')); + } + else { + $variables['title'] = $title; + } +} diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml new file mode 100644 index 0000000..c8bcf15 --- /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: + - workspace.entity_access::workspacePermissions diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml new file mode 100644 index 0000000..1c54ab9 --- /dev/null +++ b/core/modules/workspace/workspace.routing.yml @@ -0,0 +1,59 @@ +# Workspace routing definition +entity.workspace.add: + path: '/admin/structure/workspace/add' + defaults: + _controller: '\Drupal\workspace\Controller\WorkspaceController::add' + _title: 'Add workspace' + options: + _admin_route: TRUE + requirements: + _permission: 'administer workspaces+create_workspace' + +entity.workspace.add_form: + path: '/admin/structure/workspace/add/{workspace_type}' + defaults: + _controller: '\Drupal\workspace\Controller\WorkspaceController::addForm' + _title_callback: '\Drupal\workspace\Controller\WorkspaceController::getAddFormTitle' + options: + _admin_route: TRUE + requirements: + _permission: 'administer workspaces' + +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' + +# WorkspaceType routing definition +entity.workspace_type.collection: + path: '/admin/structure/workspace/types' + defaults: + _entity_list: 'workspace_type' + _title: 'Workspace types' + requirements: + _permission: 'administer site configuration' + options: + _admin_route: TRUE + +entity.workspace_type.add_form: + path: '/admin/structure/workspace/types/add' + defaults: + _entity_form: 'workspace_type.add' + _title: 'Add Workspace type' + requirements: + _permission: 'administer site configuration' + options: + _admin_route: TRUE diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml new file mode 100644 index 0000000..cd56b5f --- /dev/null +++ b/core/modules/workspace/workspace.services.yml @@ -0,0 +1,52 @@ +parameters: + workspace.default: 1 + +services: + workspace.toolbar: + class: Drupal\workspace\Toolbar + arguments: ['@entity_type.manager', '@workspace.manager', '@form_builder', '@current_user'] + workspace.paramconverter.entity_revision: + class: Drupal\workspace\ParamConverter\EntityRevisionConverter + arguments: ['@entity.manager'] + tags: + - { name: paramconverter, priority: 30 } + workspace.entity_access: + class: Drupal\workspace\EntityAccess + arguments: ['@entity_type.manager', '@workspace.manager', '%workspace.default%'] + 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.manager', '@current_user', '@logger.channel.workspace'] + tags: + - { name: service_collector, tag: workspace_negotiator, call: addNegotiator } + cache_context.workspace: + class: Drupal\workspace\WorkspaceCacheContext + arguments: ['@workspace.manager'] + tags: + - { name: cache.context } + logger.channel.workspace: + parent: logger.channel_base + arguments: ['cron'] + + # @todo: {@link https://www.drupal.org/node/2597414 Simplify the container + # definition for negotiators.} + workspace.negotiator.default: + class: Drupal\workspace\DefaultWorkspaceNegotiator + calls: + - [setContainer, ['@service_container']] + - [setCurrentUser, ['@current_user']] + - [setWorkspaceManager, ['@workspace.manager']] + tags: + - { name: workspace_negotiator, priority: 0 } + workspace.negotiator.session: + class: Drupal\workspace\SessionWorkspaceNegotiator + arguments: ['@user.private_tempstore'] + calls: + - [setContainer, ['@service_container']] + - [setCurrentUser, ['@current_user']] + - [setWorkspaceManager, ['@workspace.manager']] + tags: + - { name: workspace_negotiator, priority: 100 }