Chris@17: entityTypeManager = $entity_type_manager; Chris@17: $this->workspaceManager = $workspace_manager; Chris@17: } Chris@17: Chris@17: /** Chris@17: * {@inheritdoc} Chris@17: */ Chris@17: public static function create(ContainerInterface $container) { Chris@17: return new static( Chris@17: $container->get('entity_type.manager'), Chris@17: $container->get('workspaces.manager') Chris@17: ); Chris@17: } Chris@17: Chris@17: /** Chris@18: * Acts on entity IDs before they are loaded. Chris@17: * Chris@18: * @see hook_entity_preload() Chris@17: */ Chris@18: public function entityPreload(array $ids, $entity_type_id) { Chris@18: $entities = []; Chris@18: Chris@17: // Only run if the entity type can belong to a workspace and we are in a Chris@17: // non-default workspace. Chris@17: if (!$this->workspaceManager->shouldAlterOperations($this->entityTypeManager->getDefinition($entity_type_id))) { Chris@18: return $entities; Chris@17: } Chris@17: Chris@17: // Get a list of revision IDs for entities that have a revision set for the Chris@17: // current active workspace. If an entity has multiple revisions set for a Chris@17: // workspace, only the one with the highest ID is returned. Chris@17: $max_revision_id = 'max_target_entity_revision_id'; Chris@18: $query = $this->entityTypeManager Chris@17: ->getStorage('workspace_association') Chris@17: ->getAggregateQuery() Chris@17: ->accessCheck(FALSE) Chris@17: ->allRevisions() Chris@17: ->aggregate('target_entity_revision_id', 'MAX', NULL, $max_revision_id) Chris@17: ->groupBy('target_entity_id') Chris@17: ->condition('target_entity_type_id', $entity_type_id) Chris@18: ->condition('workspace', $this->workspaceManager->getActiveWorkspace()->id()); Chris@17: Chris@18: if ($ids) { Chris@18: $query->condition('target_entity_id', $ids, 'IN'); Chris@17: } Chris@17: Chris@18: $results = $query->execute(); Chris@18: Chris@17: if ($results) { Chris@17: /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */ Chris@17: $storage = $this->entityTypeManager->getStorage($entity_type_id); Chris@17: Chris@17: // Swap out every entity which has a revision set for the current active Chris@17: // workspace. Chris@17: $swap_revision_ids = array_column($results, $max_revision_id); Chris@17: foreach ($storage->loadMultipleRevisions($swap_revision_ids) as $revision) { Chris@17: $entities[$revision->id()] = $revision; Chris@17: } Chris@17: } Chris@18: Chris@18: return $entities; Chris@17: } Chris@17: Chris@17: /** Chris@17: * Acts on an entity before it is created or updated. Chris@17: * Chris@17: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@17: * The entity being saved. Chris@17: * Chris@17: * @see hook_entity_presave() Chris@17: */ Chris@17: public function entityPresave(EntityInterface $entity) { Chris@17: $entity_type = $entity->getEntityType(); Chris@17: Chris@17: // Only run if this is not an entity type provided by the Workspaces module Chris@17: // and we are in a non-default workspace Chris@17: if ($entity_type->getProvider() === 'workspaces' || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { Chris@17: return; Chris@17: } Chris@17: Chris@17: // Disallow any change to an unsupported entity when we are not in the Chris@17: // default workspace. Chris@17: if (!$this->workspaceManager->isEntityTypeSupported($entity_type)) { Chris@17: throw new \RuntimeException('This entity can only be saved in the default workspace.'); Chris@17: } Chris@17: Chris@18: /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */ Chris@18: if (!$entity->isNew() && !$entity->isSyncing()) { Chris@17: // Force a new revision if the entity is not replicating. Chris@17: $entity->setNewRevision(TRUE); Chris@17: Chris@17: // All entities in the non-default workspace are pending revisions, Chris@17: // regardless of their publishing status. This means that when creating Chris@17: // a published pending revision in a non-default workspace it will also be Chris@17: // a published pending revision in the default workspace, however, it will Chris@17: // become the default revision only when it is replicated to the default Chris@17: // workspace. Chris@17: $entity->isDefaultRevision(FALSE); Chris@17: } Chris@17: Chris@17: // When a new published entity is inserted in a non-default workspace, we Chris@17: // actually want two revisions to be saved: Chris@17: // - An unpublished default revision in the default ('live') workspace. Chris@17: // - A published pending revision in the current workspace. Chris@17: if ($entity->isNew() && $entity->isPublished()) { Chris@17: // Keep track of the publishing status in a dynamic property for Chris@17: // ::entityInsert(), then unpublish the default revision. Chris@17: // @todo Remove this dynamic property once we have an API for associating Chris@17: // temporary data with an entity: https://www.drupal.org/node/2896474. Chris@17: $entity->_initialPublished = TRUE; Chris@17: $entity->setUnpublished(); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Responds to the creation of a new entity. Chris@17: * Chris@17: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@17: * The entity that was just saved. Chris@17: * Chris@17: * @see hook_entity_insert() Chris@17: */ Chris@17: public function entityInsert(EntityInterface $entity) { Chris@17: /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */ Chris@17: // Only run if the entity type can belong to a workspace and we are in a Chris@17: // non-default workspace. Chris@17: if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) { Chris@17: return; Chris@17: } Chris@17: Chris@17: $this->trackEntity($entity); Chris@17: Chris@17: // When an entity is newly created in a workspace, it should be published in Chris@17: // that workspace, but not yet published on the live workspace. It is first Chris@17: // saved as unpublished for the default revision, then immediately a second Chris@17: // revision is created which is published and attached to the workspace. Chris@17: // This ensures that the published version of the entity does not 'leak' Chris@17: // into the live site. This differs from edits to existing entities where Chris@17: // there is already a valid default revision for the live workspace. Chris@17: if (isset($entity->_initialPublished)) { Chris@17: // Operate on a clone to avoid changing the entity prior to subsequent Chris@17: // hook_entity_insert() implementations. Chris@17: $pending_revision = clone $entity; Chris@17: $pending_revision->setPublished(); Chris@17: $pending_revision->isDefaultRevision(FALSE); Chris@17: $pending_revision->save(); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Responds to updates to an entity. Chris@17: * Chris@17: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@17: * The entity that was just saved. Chris@17: * Chris@17: * @see hook_entity_update() Chris@17: */ Chris@17: public function entityUpdate(EntityInterface $entity) { Chris@17: // Only run if the entity type can belong to a workspace and we are in a Chris@17: // non-default workspace. Chris@17: if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) { Chris@17: return; Chris@17: } Chris@17: Chris@17: // Only track new revisions. Chris@17: /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ Chris@17: if ($entity->getLoadedRevisionId() != $entity->getRevisionId()) { Chris@17: $this->trackEntity($entity); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Acts on an entity before it is deleted. Chris@17: * Chris@17: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@17: * The entity being deleted. Chris@17: * Chris@17: * @see hook_entity_predelete() Chris@17: */ Chris@17: public function entityPredelete(EntityInterface $entity) { Chris@17: $entity_type = $entity->getEntityType(); Chris@17: Chris@17: // Only run if this is not an entity type provided by the Workspaces module Chris@17: // and we are in a non-default workspace Chris@17: if ($entity_type->getProvider() === 'workspaces' || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) { Chris@17: return; Chris@17: } Chris@17: Chris@17: // Disallow any change to an unsupported entity when we are not in the Chris@17: // default workspace. Chris@17: if (!$this->workspaceManager->isEntityTypeSupported($entity_type)) { Chris@17: throw new \RuntimeException('This entity can only be deleted in the default workspace.'); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Updates or creates a WorkspaceAssociation entity for a given entity. Chris@17: * Chris@17: * If the passed-in entity can belong to a workspace and already has a Chris@17: * WorkspaceAssociation entity, then a new revision of this will be created with Chris@17: * the new information. Otherwise, a new WorkspaceAssociation entity is created to Chris@17: * store the passed-in entity's information. Chris@17: * Chris@18: * @param \Drupal\Core\Entity\RevisionableInterface $entity Chris@17: * The entity to update or create from. Chris@17: */ Chris@18: protected function trackEntity(RevisionableInterface $entity) { Chris@17: // If the entity is not new, check if there's an existing Chris@17: // WorkspaceAssociation entity for it. Chris@17: $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); Chris@17: if (!$entity->isNew()) { Chris@17: $workspace_associations = $workspace_association_storage->loadByProperties([ Chris@17: 'target_entity_type_id' => $entity->getEntityTypeId(), Chris@17: 'target_entity_id' => $entity->id(), Chris@17: ]); Chris@17: Chris@17: /** @var \Drupal\Core\Entity\ContentEntityInterface $workspace_association */ Chris@17: $workspace_association = reset($workspace_associations); Chris@17: } Chris@17: Chris@17: // If there was a WorkspaceAssociation entry create a new revision, Chris@17: // otherwise create a new entity with the type and ID. Chris@17: if (!empty($workspace_association)) { Chris@17: $workspace_association->setNewRevision(TRUE); Chris@17: } Chris@17: else { Chris@17: $workspace_association = $workspace_association_storage->create([ Chris@17: 'target_entity_type_id' => $entity->getEntityTypeId(), Chris@17: 'target_entity_id' => $entity->id(), Chris@17: ]); Chris@17: } Chris@17: Chris@17: // Add the revision ID and the workspace ID. Chris@17: $workspace_association->set('target_entity_revision_id', $entity->getRevisionId()); Chris@17: $workspace_association->set('workspace', $this->workspaceManager->getActiveWorkspace()->id()); Chris@17: Chris@17: // Save without updating the tracked content entity. Chris@17: $workspace_association->save(); Chris@17: } Chris@17: Chris@17: /** Chris@17: * Alters entity forms to disallow concurrent editing in multiple workspaces. Chris@17: * Chris@17: * @param array $form Chris@17: * An associative array containing the structure of the form. Chris@17: * @param \Drupal\Core\Form\FormStateInterface $form_state Chris@17: * The current state of the form. Chris@17: * @param string $form_id Chris@17: * The form ID. Chris@17: * Chris@17: * @see hook_form_alter() Chris@17: */ Chris@17: public function entityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { Chris@17: /** @var \Drupal\Core\Entity\EntityInterface $entity */ Chris@17: $entity = $form_state->getFormObject()->getEntity(); Chris@17: if (!$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) { Chris@17: return; Chris@17: } Chris@17: Chris@17: // For supported entity types, signal the fact that this form is safe to use Chris@17: // in a non-default workspace. Chris@17: // @see \Drupal\workspaces\FormOperations::validateForm() Chris@17: $form_state->set('workspace_safe', TRUE); Chris@17: Chris@17: // Add an entity builder to the form which marks the edited entity object as Chris@17: // a pending revision. This is needed so validation constraints like Chris@17: // \Drupal\path\Plugin\Validation\Constraint\PathAliasConstraintValidator Chris@17: // know in advance (before hook_entity_presave()) that the new revision will Chris@17: // be a pending one. Chris@17: $active_workspace = $this->workspaceManager->getActiveWorkspace(); Chris@17: if (!$active_workspace->isDefaultWorkspace()) { Chris@17: $form['#entity_builders'][] = [get_called_class(), 'entityFormEntityBuild']; Chris@17: } Chris@17: Chris@17: /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ Chris@17: $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); Chris@17: if ($workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity)) { Chris@17: // An entity can only be edited in one workspace. Chris@17: $workspace_id = reset($workspace_ids); Chris@17: Chris@17: if ($workspace_id !== $active_workspace->id()) { Chris@17: $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id); Chris@17: Chris@17: $form['#markup'] = $this->t('The content is being edited in the %label workspace.', ['%label' => $workspace->label()]); Chris@17: $form['#access'] = FALSE; Chris@17: } Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Entity builder that marks all supported entities as pending revisions. Chris@17: */ Chris@17: public static function entityFormEntityBuild($entity_type_id, RevisionableInterface $entity, &$form, FormStateInterface &$form_state) { Chris@17: // Set the non-default revision flag so that validation constraints are also Chris@17: // aware that a pending revision is about to be created. Chris@17: $entity->isDefaultRevision(FALSE); Chris@17: } Chris@17: Chris@17: }