Update Composer, update everything

This commit is contained in:
Oliver Davies 2018-11-23 12:29:20 +00:00
parent ea3e94409f
commit dda5c284b6
19527 changed files with 1135420 additions and 351004 deletions

View file

@ -0,0 +1,201 @@
<?php
namespace Drupal\workspaces\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\UserInterface;
use Drupal\workspaces\WorkspaceInterface;
/**
* The workspace entity class.
*
* @ContentEntityType(
* id = "workspace",
* label = @Translation("Workspace"),
* label_collection = @Translation("Workspaces"),
* label_singular = @Translation("workspace"),
* label_plural = @Translation("workspaces"),
* label_count = @PluralTranslation(
* singular = "@count workspace",
* plural = "@count workspaces"
* ),
* handlers = {
* "list_builder" = "\Drupal\workspaces\WorkspaceListBuilder",
* "access" = "Drupal\workspaces\WorkspaceAccessControlHandler",
* "route_provider" = {
* "html" = "\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
* },
* "form" = {
* "default" = "\Drupal\workspaces\Form\WorkspaceForm",
* "add" = "\Drupal\workspaces\Form\WorkspaceForm",
* "edit" = "\Drupal\workspaces\Form\WorkspaceForm",
* "delete" = "\Drupal\workspaces\Form\WorkspaceDeleteForm",
* "activate" = "\Drupal\workspaces\Form\WorkspaceActivateForm",
* "deploy" = "\Drupal\workspaces\Form\WorkspaceDeployForm",
* },
* },
* admin_permission = "administer workspaces",
* base_table = "workspace",
* revision_table = "workspace_revision",
* data_table = "workspace_field_data",
* revision_data_table = "workspace_field_revision",
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* "label" = "label",
* "uid" = "uid",
* },
* links = {
* "add-form" = "/admin/config/workflow/workspaces/add",
* "edit-form" = "/admin/config/workflow/workspaces/manage/{workspace}/edit",
* "delete-form" = "/admin/config/workflow/workspaces/manage/{workspace}/delete",
* "activate-form" = "/admin/config/workflow/workspaces/manage/{workspace}/activate",
* "deploy-form" = "/admin/config/workflow/workspaces/manage/{workspace}/deploy",
* "collection" = "/admin/config/workflow/workspaces",
* },
* )
*/
class Workspace extends ContentEntityBase implements WorkspaceInterface {
use EntityChangedTrait;
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['id'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Workspace ID'))
->setDescription(new TranslatableMarkup('The workspace ID.'))
->setSetting('max_length', 128)
->setRequired(TRUE)
->addConstraint('UniqueField')
->addConstraint('DeletedWorkspace')
->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[a-z0-9_]+$/']]);
$fields['label'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Workspace name'))
->setDescription(new TranslatableMarkup('The workspace name.'))
->setRevisionable(TRUE)
->setSetting('max_length', 128)
->setRequired(TRUE);
$fields['uid'] = BaseFieldDefinition::create('entity_reference')
->setLabel(new TranslatableMarkup('Owner'))
->setDescription(new TranslatableMarkup('The workspace owner.'))
->setRevisionable(TRUE)
->setSetting('target_type', 'user')
->setDefaultValueCallback('Drupal\workspaces\Entity\Workspace::getCurrentUserId')
->setDisplayOptions('form', [
'type' => 'entity_reference_autocomplete',
'weight' => 5,
])
->setDisplayConfigurable('form', TRUE);
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(new TranslatableMarkup('Changed'))
->setDescription(new TranslatableMarkup('The time that the workspace was last edited.'))
->setRevisionable(TRUE);
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(new TranslatableMarkup('Created'))
->setDescription(new TranslatableMarkup('The time that the workspaces was created.'));
return $fields;
}
/**
* {@inheritdoc}
*/
public function publish() {
return \Drupal::service('workspaces.operation_factory')->getPublisher($this)->publish();
}
/**
* {@inheritdoc}
*/
public function isDefaultWorkspace() {
return $this->id() === static::DEFAULT_WORKSPACE;
}
/**
* {@inheritdoc}
*/
public function getCreatedTime() {
return $this->get('created')->value;
}
/**
* {@inheritdoc}
*/
public function setCreatedTime($created) {
return $this->set('created', (int) $created);
}
/**
* {@inheritdoc}
*/
public function getOwner() {
return $this->get('uid')->entity;
}
/**
* {@inheritdoc}
*/
public function setOwner(UserInterface $account) {
return $this->set('uid', $account->id());
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->get('uid')->target_id;
}
/**
* {@inheritdoc}
*/
public function setOwnerId($uid) {
return $this->set('uid', $uid);
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage, array $entities) {
parent::postDelete($storage, $entities);
// Add the IDs of the deleted workspaces to the list of workspaces that will
// be purged on cron.
$state = \Drupal::state();
$deleted_workspace_ids = $state->get('workspace.deleted', []);
unset($entities[static::DEFAULT_WORKSPACE]);
$deleted_workspace_ids += array_combine(array_keys($entities), array_keys($entities));
$state->set('workspace.deleted', $deleted_workspace_ids);
// Trigger a batch purge to allow empty workspaces to be deleted
// immediately.
\Drupal::service('workspaces.manager')->purgeDeletedWorkspacesBatch();
}
/**
* Default value callback for 'uid' base field definition.
*
* @see ::baseFieldDefinitions()
*
* @return int[]
* An array containing the ID of the current user.
*/
public static function getCurrentUserId() {
return [\Drupal::currentUser()->id()];
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace Drupal\workspaces\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines the Workspace association entity.
*
* @ContentEntityType(
* id = "workspace_association",
* label = @Translation("Workspace association"),
* label_collection = @Translation("Workspace associations"),
* label_singular = @Translation("workspace association"),
* label_plural = @Translation("workspace associations"),
* label_count = @PluralTranslation(
* singular = "@count workspace association",
* plural = "@count workspace associations"
* ),
* handlers = {
* "storage" = "Drupal\workspaces\WorkspaceAssociationStorage"
* },
* base_table = "workspace_association",
* revision_table = "workspace_association_revision",
* internal = TRUE,
* entity_keys = {
* "id" = "id",
* "revision" = "revision_id",
* "uuid" = "uuid",
* }
* )
*
* @internal
* This entity is marked internal because it should not be used directly to
* alter the workspace an entity belongs to.
*/
class WorkspaceAssociation extends ContentEntityBase {
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['workspace'] = BaseFieldDefinition::create('entity_reference')
->setLabel(new TranslatableMarkup('workspace'))
->setDescription(new TranslatableMarkup('The workspace of the referenced content.'))
->setSetting('target_type', 'workspace')
->setRequired(TRUE)
->setRevisionable(TRUE)
->addConstraint('workspace', []);
$fields['target_entity_type_id'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Content entity type ID'))
->setDescription(new TranslatableMarkup('The ID of the content entity type associated with this workspace.'))
->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['target_entity_id'] = BaseFieldDefinition::create('integer')
->setLabel(new TranslatableMarkup('Content entity ID'))
->setDescription(new TranslatableMarkup('The ID of the content entity associated with this workspace.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
$fields['target_entity_revision_id'] = BaseFieldDefinition::create('integer')
->setLabel(new TranslatableMarkup('Content entity revision ID'))
->setDescription(new TranslatableMarkup('The revision ID of the content entity associated with this workspace.'))
->setRequired(TRUE)
->setRevisionable(TRUE);
return $fields;
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Service wrapper for hooks relating to entity access control.
*
* @internal
*/
class EntityAccess implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new EntityAccess instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager')
);
}
/**
* Implements a hook bridge for hook_entity_access().
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check access for.
* @param string $operation
* The operation being performed.
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*
* @see hook_entity_access()
*/
public function entityOperationAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Workspaces themselves are handled by their own access handler and we
// should not try to do any access checks for entity types that can not
// belong to a workspace.
if ($entity->getEntityTypeId() === 'workspace' || !$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) {
return AccessResult::neutral();
}
return $this->bypassAccessResult($account);
}
/**
* Implements a hook bridge for hook_entity_create_access().
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
* @param array $context
* The context of the access check.
* @param string $entity_bundle
* The bundle of the entity.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*
* @see hook_entity_create_access()
*/
public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) {
// Workspaces themselves are handled by their own access handler and we
// should not try to do any access checks for entity types that can not
// belong to a workspace.
$entity_type = $this->entityTypeManager->getDefinition($context['entity_type_id']);
if ($entity_type->id() === 'workspace' || !$this->workspaceManager->isEntityTypeSupported($entity_type)) {
return AccessResult::neutral();
}
return $this->bypassAccessResult($account);
}
/**
* Checks the 'bypass' permissions.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account making the to check access for.
*
* @return \Drupal\Core\Access\AccessResult
* The result of the access check.
*/
protected function bypassAccessResult(AccountInterface $account) {
// This approach assumes that the current "global" active workspace is
// correct, i.e. 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::allowedIf($active_workspace->getOwnerId() == $account->id())->cachePerUser()->addCacheableDependency($active_workspace)
->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace'));
}
}

View file

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

View file

@ -0,0 +1,53 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Query\QueryBase;
use Drupal\Core\Entity\Query\Sql\pgsql\QueryFactory as BaseQueryFactory;
use Drupal\workspaces\WorkspaceManagerInterface;
/**
* Workspaces PostgreSQL-specific entity query implementation.
*/
class PgsqlQueryFactory extends BaseQueryFactory {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a PgsqlQueryFactory object.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection used by the entity query.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
$this->connection = $connection;
$this->workspaceManager = $workspace_manager;
$this->namespaces = QueryBase::getNamespaces($this);
}
/**
* {@inheritdoc}
*/
public function get(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'Query');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
}
/**
* {@inheritdoc}
*/
public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Entity\Query\Sql\Query as BaseQuery;
/**
* Alters entity queries to use a workspace revision instead of the default one.
*/
class Query extends BaseQuery {
use QueryTrait {
prepare as traitPrepare;
}
/**
* Stores the SQL expressions used to build the SQL query.
*
* The array is keyed by the expression alias and the values are the actual
* expressions.
*
* @var array
* An array of expressions.
*/
protected $sqlExpressions = [];
/**
* {@inheritdoc}
*/
public function prepare() {
$this->traitPrepare();
// If the prepare() method from the trait decided that we need to alter this
// query, we need to re-define the the key fields for fetchAllKeyed() as SQL
// expressions.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
$id_field = $this->entityType->getKey('id');
$revision_field = $this->entityType->getKey('revision');
// Since the query is against the base table, we have to take into account
// that the revision ID might come from the workspace_association
// relationship, and, as a consequence, the revision ID field is no longer
// a simple SQL field but an expression.
$this->sqlFields = [];
$this->sqlExpressions[$revision_field] = "COALESCE(workspace_association.target_entity_revision_id, base_table.$revision_field)";
$this->sqlExpressions[$id_field] = "base_table.$id_field";
}
return $this;
}
/**
* {@inheritdoc}
*/
protected function finish() {
foreach ($this->sqlExpressions as $alias => $expression) {
$this->sqlQuery->addExpression($expression, $alias);
}
return parent::finish();
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Entity\Query\Sql\QueryAggregate as BaseQueryAggregate;
/**
* Alters aggregate entity queries to use a workspace revision if possible.
*/
class QueryAggregate extends BaseQueryAggregate {
use QueryTrait {
prepare as traitPrepare;
}
/**
* {@inheritdoc}
*/
public function prepare() {
// Aggregate entity queries do not return an array of entity IDs keyed by
// revision IDs, they only return the values of the aggregated fields, so we
// don't need to add any expressions like we do in
// \Drupal\workspaces\EntityQuery\Query::prepare().
$this->traitPrepare();
// Throw away the ID fields.
$this->sqlFields = [];
return $this;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Query\QueryBase;
use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory;
use Drupal\workspaces\WorkspaceManagerInterface;
/**
* Workspaces-specific entity query implementation.
*/
class QueryFactory extends BaseQueryFactory {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a QueryFactory object.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection used by the entity query.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
$this->connection = $connection;
$this->workspaceManager = $workspace_manager;
$this->namespaces = QueryBase::getNamespaces($this);
}
/**
* {@inheritdoc}
*/
public function get(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'Query');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
}
/**
* {@inheritdoc}
*/
public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
$class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
/**
* Provides workspaces-specific helpers for altering entity queries.
*/
trait QueryTrait {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a Query object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param string $conjunction
* - AND: all of the conditions on the query need to match.
* - OR: at least one of the conditions on the query need to match.
* @param \Drupal\Core\Database\Connection $connection
* The database connection to run the query against.
* @param array $namespaces
* List of potential namespaces of the classes belonging to this query.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(EntityTypeInterface $entity_type, $conjunction, Connection $connection, array $namespaces, WorkspaceManagerInterface $workspace_manager) {
parent::__construct($entity_type, $conjunction, $connection, $namespaces);
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public function prepare() {
parent::prepare();
// Do not alter entity revision queries.
// @todo How about queries for the latest revision? Should we alter them to
// look for the latest workspace-specific revision?
if ($this->allRevisions) {
return $this;
}
// Only alter the query if the active workspace is not the default one and
// the entity type is supported.
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if (!$active_workspace->isDefaultWorkspace() && $this->workspaceManager->isEntityTypeSupported($this->entityType)) {
$this->sqlQuery->addMetaData('active_workspace_id', $active_workspace->id());
$this->sqlQuery->addMetaData('simple_query', FALSE);
// LEFT JOIN 'workspace_association' to the base table of the query so we
// can properly include live content along with a possible workspace
// revision.
$id_field = $this->entityType->getKey('id');
$this->sqlQuery->leftJoin('workspace_association', 'workspace_association', "%alias.target_entity_type_id = '{$this->entityTypeId}' AND %alias.target_entity_id = base_table.$id_field AND %alias.workspace = '{$active_workspace->id()}'");
}
return $this;
}
}

View file

@ -0,0 +1,156 @@
<?php
namespace Drupal\workspaces\EntityQuery;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityType;
use Drupal\Core\Entity\Query\Sql\Tables as BaseTables;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
/**
* Alters entity queries to use a workspace revision instead of the default one.
*/
class Tables extends BaseTables {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Workspace association table array, key is base table name, value is alias.
*
* @var array
*/
protected $contentWorkspaceTables = [];
/**
* Keeps track of the entity type IDs for each base table of the query.
*
* The array is keyed by the base table alias and the values are entity type
* IDs.
*
* @var array
*/
protected $baseTablesEntityType = [];
/**
* {@inheritdoc}
*/
public function __construct(SelectInterface $sql_query) {
parent::__construct($sql_query);
$this->workspaceManager = \Drupal::service('workspaces.manager');
// The join between the first 'workspace_association' table and base table
// of the query is done in
// \Drupal\workspaces\EntityQuery\QueryTrait::prepare(), so we need to
// initialize its entry manually.
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
$this->contentWorkspaceTables['base_table'] = 'workspace_association';
$this->baseTablesEntityType['base_table'] = $this->sqlQuery->getMetaData('entity_type');
}
}
/**
* {@inheritdoc}
*/
public function addField($field, $type, $langcode) {
// The parent method uses shared and dedicated revision tables only when the
// entity query is instructed to query all revisions. However, if we are
// looking for workspace-specific revisions, we have to force the parent
// method to always pick the revision tables if the field being queried is
// revisionable.
if ($active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id')) {
$previous_all_revisions = $this->sqlQuery->getMetaData('all_revisions');
$this->sqlQuery->addMetaData('all_revisions', TRUE);
}
$alias = parent::addField($field, $type, $langcode);
// Restore the 'all_revisions' metadata because we don't want to interfere
// with the rest of the query.
if (isset($previous_all_revisions)) {
$this->sqlQuery->addMetaData('all_revisions', $previous_all_revisions);
}
return $alias;
}
/**
* {@inheritdoc}
*/
protected function addJoin($type, $table, $join_condition, $langcode, $delta = NULL) {
if ($this->sqlQuery->getMetaData('active_workspace_id')) {
// The join condition for a shared or dedicated field table is in the form
// of "%alias.$id_field = $base_table.$id_field". Whenever we join a field
// table we have to check:
// 1) if $base_table is of an entity type that can belong to a workspace;
// 2) if $id_field is the revision key of that entity type or the special
// 'revision_id' string used when joining dedicated field tables.
// If those two conditions are met, we have to update the join condition
// to also look for a possible workspace-specific revision using COALESCE.
$condition_parts = explode(' = ', $join_condition);
list($base_table, $id_field) = explode('.', $condition_parts[1]);
if (isset($this->baseTablesEntityType[$base_table])) {
$entity_type_id = $this->baseTablesEntityType[$base_table];
$revision_key = $this->entityManager->getDefinition($entity_type_id)->getKey('revision');
if ($id_field === $revision_key || $id_field === 'revision_id') {
$workspace_association_table = $this->contentWorkspaceTables[$base_table];
$join_condition = "{$condition_parts[0]} = COALESCE($workspace_association_table.target_entity_revision_id, {$condition_parts[1]})";
}
}
}
return parent::addJoin($type, $table, $join_condition, $langcode, $delta);
}
/**
* {@inheritdoc}
*/
protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
$next_base_table_alias = parent::addNextBaseTable($entity_type, $table, $sql_column, $field_storage);
$active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id');
if ($active_workspace_id && $this->workspaceManager->isEntityTypeSupported($entity_type)) {
$this->addWorkspaceAssociationJoin($entity_type->id(), $next_base_table_alias, $active_workspace_id);
}
return $next_base_table_alias;
}
/**
* Adds a new join to the 'workspace_association' table for an entity base table.
*
* This method assumes that the active workspace has already been determined
* to be a non-default workspace.
*
* @param string $entity_type_id
* The ID of the entity type whose base table we are joining.
* @param string $base_table_alias
* The alias of the entity type's base table.
* @param string $active_workspace_id
* The ID of the active workspace.
*
* @return string
* The alias of the joined table.
*/
public function addWorkspaceAssociationJoin($entity_type_id, $base_table_alias, $active_workspace_id) {
if (!isset($this->contentWorkspaceTables[$base_table_alias])) {
$entity_type = $this->entityManager->getDefinition($entity_type_id);
$id_field = $entity_type->getKey('id');
// LEFT join the Workspace association entity's table so we can properly
// include live content along with a possible workspace-specific revision.
$this->contentWorkspaceTables[$base_table_alias] = $this->sqlQuery->leftJoin('workspace_association', NULL, "%alias.target_entity_type_id = '$entity_type_id' AND %alias.target_entity_id = $base_table_alias.$id_field AND %alias.workspace = '$active_workspace_id'");
$this->baseTablesEntityType[$base_table_alias] = $entity_type->id();
}
return $this->contentWorkspaceTables[$base_table_alias];
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Manipulates entity type information.
*
* This class contains primarily bridged hooks for compile-time or
* cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
*
* @internal
*/
class EntityTypeInfo implements ContainerInjectionInterface {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new EntityTypeInfo instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager')
);
}
/**
* Adds the "EntityWorkspaceConflict" constraint to eligible entity types.
*
* @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
* An associative array of all entity type definitions, keyed by the entity
* type name. Passed by reference.
*
* @see hook_entity_type_build()
*/
public function entityTypeBuild(array &$entity_types) {
foreach ($entity_types as $entity_type) {
if ($this->workspaceManager->isEntityTypeSupported($entity_type)) {
$entity_type->addConstraint('EntityWorkspaceConflict');
}
}
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\workspaces\WorkspaceAccessException;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Handle activation of a workspace on administrative pages.
*/
class WorkspaceActivateForm extends EntityConfirmFormBase implements WorkspaceFormInterface {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The workspace replication manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new WorkspaceActivateForm.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager, MessengerInterface $messenger) {
$this->workspaceManager = $workspace_manager;
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Activate the %workspace workspace.', ['%workspace' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->entity->toUrl('collection');
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
// Content entity forms do not use the parent's #after_build callback.
unset($form['#after_build']);
return $form;
}
/**
* {@inheritdoc}
*/
public function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['cancel']['#attributes']['class'][] = 'dialog-cancel';
return $actions;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
try {
$this->workspaceManager->setActiveWorkspace($this->entity);
$this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $this->entity->label()]));
$form_state->setRedirect('<front>');
}
catch (WorkspaceAccessException $e) {
$this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $this->entity->label()]));
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Entity\ContentEntityDeleteForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form for deleting a workspace.
*
* @internal
*/
class WorkspaceDeleteForm extends ContentEntityDeleteForm implements WorkspaceFormInterface {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$source_rev_diff = $this->entityTypeManager->getStorage('workspace_association')->getTrackedEntities($this->entity->id());
$items = [];
foreach ($source_rev_diff as $entity_type_id => $revision_ids) {
$label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel();
$items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]);
}
$form['revisions'] = [
'#theme' => 'item_list',
'#title' => $this->t('The following will also be deleted:'),
'#items' => $items,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('This action cannot be undone, and will also delete all content created in this workspace.');
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\workspaces\WorkspaceOperationFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides the workspace deploy form.
*/
class WorkspaceDeployForm extends ContentEntityForm implements WorkspaceFormInterface {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* The workspace operation factory.
*
* @var \Drupal\workspaces\WorkspaceOperationFactory
*/
protected $workspaceOperationFactory;
/**
* Constructs a new WorkspaceDeployForm.
*
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository service.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
* The entity type bundle service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
* @param \Drupal\workspaces\WorkspaceOperationFactory $workspace_operation_factory
* The workspace operation factory service.
*/
public function __construct(EntityRepositoryInterface $entity_repository, EntityTypeBundleInfoInterface $entity_type_bundle_info, TimeInterface $time, MessengerInterface $messenger, WorkspaceOperationFactory $workspace_operation_factory) {
parent::__construct($entity_repository, $entity_type_bundle_info, $time);
$this->messenger = $messenger;
$this->workspaceOperationFactory = $workspace_operation_factory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.repository'),
$container->get('entity_type.bundle.info'),
$container->get('datetime.time'),
$container->get('messenger'),
$container->get('workspaces.operation_factory')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
$workspace_publisher = $this->workspaceOperationFactory->getPublisher($this->entity);
$args = [
'%source_label' => $this->entity->label(),
'%target_label' => $workspace_publisher->getTargetLabel(),
];
$form['#title'] = $this->t('Deploy %source_label workspace', $args);
// List the changes that can be pushed.
if ($source_rev_diff = $workspace_publisher->getDifferringRevisionIdsOnSource()) {
$total_count = $workspace_publisher->getNumberOfChangesOnSource();
$form['deploy'] = [
'#theme' => 'item_list',
'#title' => $this->formatPlural($total_count, 'There is @count item that can be deployed from %source_label to %target_label', 'There are @count items that can be deployed from %source_label to %target_label', $args),
'#items' => [],
'#total_count' => $total_count,
];
foreach ($source_rev_diff as $entity_type_id => $revision_difference) {
$form['deploy']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
}
}
// If there are no changes to push or pull, show an informational message.
if (!isset($form['deploy']) && !isset($form['refresh'])) {
$form['help'] = [
'#markup' => $this->t('There are no changes that can be deployed from %source_label to %target_label.', $args),
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function actions(array $form, FormStateInterface $form_state) {
$elements = parent::actions($form, $form_state);
unset($elements['delete']);
$workspace_publisher = $this->workspaceOperationFactory->getPublisher($this->entity);
if (isset($form['deploy'])) {
$total_count = $form['deploy']['#total_count'];
$elements['submit']['#value'] = $this->formatPlural($total_count, 'Deploy @count item to @target', 'Deploy @count items to @target', ['@target' => $workspace_publisher->getTargetLabel()]);
$elements['submit']['#submit'] = ['::submitForm', '::deploy'];
}
else {
// Do not allow the 'Deploy' operation if there's nothing to push.
$elements['submit']['#value'] = $this->t('Deploy');
$elements['submit']['#disabled'] = TRUE;
}
$elements['cancel'] = [
'#type' => 'link',
'#title' => $this->t('Cancel'),
'#attributes' => ['class' => ['button']],
'#url' => $this->entity->toUrl('collection'),
];
return $elements;
}
/**
* Form submission handler; deploys the content to the workspace's target.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public function deploy(array &$form, FormStateInterface $form_state) {
$workspace = $this->entity;
try {
$workspace->publish();
$this->messenger->addMessage($this->t('Successful deployment.'));
}
catch (\Exception $e) {
$this->messenger->addMessage($this->t('Deployment failed. All errors have been logged.'), 'error');
}
}
}

View file

@ -0,0 +1,158 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form controller for the workspace edit forms.
*/
class WorkspaceForm extends ContentEntityForm implements WorkspaceFormInterface {
/**
* The workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $entity;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new WorkspaceForm.
*
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
* The entity type bundle service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(EntityRepositoryInterface $entity_repository, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
parent::__construct($entity_repository, $entity_type_bundle_info, $time);
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.repository'),
$container->get('messenger'),
$container->get('entity_type.bundle.info'),
$container->get('datetime.time')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$workspace = $this->entity;
if ($this->operation == 'edit') {
$form['#title'] = $this->t('Edit workspace %label', ['%label' => $workspace->label()]);
}
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $workspace->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#title' => $this->t('Workspace ID'),
'#maxlength' => 255,
'#default_value' => $workspace->id(),
'#disabled' => !$workspace->isNew(),
'#machine_name' => [
'exists' => '\Drupal\workspaces\Entity\Workspace::load',
],
'#element_validate' => [],
];
return parent::form($form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function getEditedFieldNames(FormStateInterface $form_state) {
return array_merge([
'label',
'id',
], parent::getEditedFieldNames($form_state));
}
/**
* {@inheritdoc}
*/
protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
// Manually flag violations of fields not handled by the form display. This
// is necessary as entity form displays only flag violations for fields
// contained in the display.
$field_names = [
'label',
'id',
];
foreach ($violations->getByFields($field_names) as $violation) {
list($field_name) = explode('.', $violation->getPropertyPath(), 2);
$form_state->setErrorByName($field_name, $violation->getMessage());
}
parent::flagViolations($violations, $form, $form_state);
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$workspace = $this->entity;
$workspace->setNewRevision(TRUE);
$status = $workspace->save();
$info = ['%info' => $workspace->label()];
$context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()];
$logger = $this->logger('workspaces');
if ($status == SAVED_UPDATED) {
$logger->notice('@type: updated %info.', $context);
$this->messenger->addMessage($this->t('Workspace %info has been updated.', $info));
}
else {
$logger->notice('@type: added %info.', $context);
$this->messenger->addMessage($this->t('Workspace %info has been created.', $info));
}
if ($workspace->id()) {
$form_state->setValue('id', $workspace->id());
$form_state->set('id', $workspace->id());
$collection_url = $workspace->toUrl('collection');
$redirect = $collection_url->access() ? $collection_url : Url::fromRoute('<front>');
$form_state->setRedirectUrl($redirect);
}
else {
$this->messenger->addError($this->t('The workspace could not be saved.'));
$form_state->setRebuild();
}
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Form\FormInterface;
/**
* Defines interface for workspace forms so they can be easily distinguished.
*
* @internal
*/
interface WorkspaceFormInterface extends FormInterface {}

View file

@ -0,0 +1,131 @@
<?php
namespace Drupal\workspaces\Form;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\workspaces\WorkspaceAccessException;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form that activates a different workspace.
*/
class WorkspaceSwitcherForm extends FormBase implements WorkspaceFormInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The workspace entity storage handler.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $workspaceStorage;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new WorkspaceSwitcherForm.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
$this->workspaceManager = $workspace_manager;
$this->workspaceStorage = $entity_type_manager->getStorage('workspace');
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager'),
$container->get('entity_type.manager'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workspace_switcher_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$workspaces = $this->workspaceStorage->loadMultiple();
$workspace_labels = [];
foreach ($workspaces as $workspace) {
$workspace_labels[$workspace->id()] = $workspace->label();
}
$active_workspace = $this->workspaceManager->getActiveWorkspace();
unset($workspace_labels[$active_workspace->id()]);
$form['current'] = [
'#type' => 'item',
'#title' => $this->t('Current workspace'),
'#markup' => $active_workspace->label(),
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
];
$form['workspace_id'] = [
'#type' => 'select',
'#title' => $this->t('Select workspace'),
'#required' => TRUE,
'#options' => $workspace_labels,
'#wrapper_attributes' => [
'class' => ['container-inline'],
],
];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Activate'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$id = $form_state->getValue('workspace_id');
/** @var \Drupal\workspaces\WorkspaceInterface $workspace */
$workspace = $this->workspaceStorage->load($id);
try {
$this->workspaceManager->setActiveWorkspace($workspace);
$this->messenger->addMessage($this->t('%workspace_label is now the active workspace.', ['%workspace_label' => $workspace->label()]));
}
catch (WorkspaceAccessException $e) {
$this->messenger->addError($this->t('You do not have access to activate the %workspace_label workspace.', ['%workspace_label' => $workspace->label()]));
}
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Form\ViewsExposedForm;
use Drupal\workspaces\Form\WorkspaceFormInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to form operations.
*
* @internal
*/
class FormOperations implements ContainerInjectionInterface {
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new FormOperations instance.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.manager')
);
}
/**
* Alters forms to disallow editing in non-default workspaces.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form ID.
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
// No alterations are needed in the default workspace.
if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
return;
}
// Add an additional validation step for every form if we are in a
// non-default workspace.
$this->addWorkspaceValidation($form);
// If a form has already been marked as safe or not to submit in a
// non-default workspace, we don't have anything else to do.
if ($form_state->has('workspace_safe')) {
return;
}
// No forms are safe to submit in a non-default workspace by default, except
// for the whitelisted ones defined below.
$workspace_safe = FALSE;
// Whitelist a few forms that we know are safe to submit.
$form_object = $form_state->getFormObject();
$is_workspace_form = $form_object instanceof WorkspaceFormInterface;
$is_search_form = in_array($form_object->getFormId(), ['search_block_form', 'search_form'], TRUE);
$is_views_exposed_form = $form_object instanceof ViewsExposedForm;
if ($is_workspace_form || $is_search_form || $is_views_exposed_form) {
$workspace_safe = TRUE;
}
$form_state->set('workspace_safe', $workspace_safe);
}
/**
* Adds our validation handler recursively on each element of a form.
*
* @param array &$element
* An associative array containing the structure of the form.
*/
protected function addWorkspaceValidation(array &$element) {
// Recurse through all children and add our validation handler if needed.
foreach (Element::children($element) as $key) {
if (isset($element[$key]) && $element[$key]) {
$this->addWorkspaceValidation($element[$key]);
}
}
if (isset($element['#validate'])) {
$element['#validate'][] = [get_called_class(), 'validateDefaultWorkspace'];
}
}
/**
* Validation handler which sets a validation error for all unsupported forms.
*/
public static function validateDefaultWorkspace(array &$form, FormStateInterface $form_state) {
if ($form_state->get('workspace_safe') !== TRUE) {
$form_state->setError($form, new TranslatableMarkup('This form can only be submitted in the default workspace.'));
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines the default workspace negotiator.
*/
class DefaultWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
/**
* The workspace storage handler.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $workspaceStorage;
/**
* The default workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $defaultWorkspace;
/**
* Constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->workspaceStorage = $entity_type_manager->getStorage('workspace');
}
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace(Request $request) {
if (!$this->defaultWorkspace) {
$default_workspace = $this->workspaceStorage->create([
'id' => WorkspaceInterface::DEFAULT_WORKSPACE,
'label' => Unicode::ucwords(WorkspaceInterface::DEFAULT_WORKSPACE),
]);
$default_workspace->enforceIsNew(FALSE);
$this->defaultWorkspace = $default_workspace;
}
return $this->defaultWorkspace;
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines the query parameter workspace negotiator.
*/
class QueryParameterWorkspaceNegotiator extends SessionWorkspaceNegotiator {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return is_string($request->query->get('workspace')) && parent::applies($request);
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace(Request $request) {
$workspace_id = $request->query->get('workspace');
if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) {
$this->setActiveWorkspace($workspace);
return $workspace;
}
return NULL;
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
/**
* Defines the session workspace negotiator.
*/
class SessionWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The session.
*
* @var \Symfony\Component\HttpFoundation\Session\Session
*/
protected $session;
/**
* The workspace storage handler.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $workspaceStorage;
/**
* Constructor.
*
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Symfony\Component\HttpFoundation\Session\Session $session
* The session.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(AccountInterface $current_user, Session $session, EntityTypeManagerInterface $entity_type_manager) {
$this->currentUser = $current_user;
$this->session = $session;
$this->workspaceStorage = $entity_type_manager->getStorage('workspace');
}
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
// This negotiator only applies if the current user is authenticated.
return $this->currentUser->isAuthenticated();
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace(Request $request) {
$workspace_id = $this->session->get('active_workspace_id');
if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) {
return $workspace;
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {
$this->session->set('active_workspace_id', $workspace->id());
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Drupal\workspaces\Negotiator;
use Drupal\workspaces\WorkspaceInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Workspace negotiators provide a way to get the active workspace.
*
* \Drupal\workspaces\WorkspaceManager acts as the service collector for
* Workspace negotiators.
*/
interface WorkspaceNegotiatorInterface {
/**
* Checks whether the negotiator applies to the current request or not.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return bool
* TRUE if the negotiator applies for the current request, FALSE otherwise.
*/
public function applies(Request $request);
/**
* Gets the negotiated workspace, if any.
*
* Note that it is the responsibility of each implementation to check whether
* the negotiated workspace actually exists in the storage.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return \Drupal\workspaces\WorkspaceInterface|null
* The negotiated workspace or NULL if the negotiator could not determine a
* valid workspace.
*/
public function getActiveWorkspace(Request $request);
/**
* Sets the negotiated workspace.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace entity.
*/
public function setActiveWorkspace(WorkspaceInterface $workspace);
}

View file

@ -0,0 +1,84 @@
<?php
namespace Drupal\workspaces\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\workspaces\Form\WorkspaceSwitcherForm;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a 'Workspace switcher' block.
*
* @Block(
* id = "workspace_switcher",
* admin_label = @Translation("Workspace switcher"),
* category = @Translation("Workspace"),
* )
*/
class WorkspaceSwitcherBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new WorkspaceSwitcherBlock instance.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->formBuilder = $form_builder;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('form_builder'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function build() {
$build = [
'form' => $this->formBuilder->getForm(WorkspaceSwitcherForm::class),
'#cache' => [
'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(),
'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(),
],
];
return $build;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Deleted workspace constraint.
*
* @Constraint(
* id = "DeletedWorkspace",
* label = @Translation("Deleted workspace", context = "Validation"),
* )
*/
class DeletedWorkspaceConstraint extends Constraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'A workspace with this ID has been deleted but data still exists for it.';
}

View file

@ -0,0 +1,64 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\workspaces\WorkspaceAssociationStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if data still exists for a deleted workspace ID.
*/
class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The workspace association storage.
*
* @var \Drupal\workspaces\WorkspaceAssociationStorageInterface
*/
protected $workspaceAssociationStorage;
/**
* Creates a new DeletedWorkspaceConstraintValidator instance.
*
* @param \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage
* The workspace association storage.
*/
public function __construct(WorkspaceAssociationStorageInterface $workspace_association_storage) {
$this->workspaceAssociationStorage = $workspace_association_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')->getStorage('workspace_association')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Field\FieldItemListInterface $value */
// This constraint applies only to newly created workspace entities.
if (!isset($value) || !$value->getEntity()->isNew()) {
return;
}
$count = $this->workspaceAssociationStorage
->getQuery()
->allRevisions()
->accessCheck(FALSE)
->condition('workspace', $value->getEntity()->id())
->count()
->execute();
if ($count) {
$this->context->addViolation($constraint->message);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Validation constraint for an entity being edited in multiple workspaces.
*
* @Constraint(
* id = "EntityWorkspaceConflict",
* label = @Translation("Entity workspace conflict", context = "Validation"),
* type = {"entity"}
* )
*/
class EntityWorkspaceConflictConstraint extends Constraint {
/**
* The default violation message.
*
* @var string
*/
public $message = 'The content is being edited in the %label workspace. As a result, your changes cannot be saved.';
}

View file

@ -0,0 +1,77 @@
<?php
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the EntityWorkspaceConflict constraint.
*/
class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs an EntityUntranslatableFieldsConstraintValidator object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('workspaces.manager')
);
}
/**
* {@inheritdoc}
*/
public function validate($entity, Constraint $constraint) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
if (isset($entity) && !$entity->isNew()) {
/** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */
$workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
$workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity);
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($workspace_ids && !in_array($active_workspace->id(), $workspace_ids, TRUE)) {
// An entity can only be edited in one workspace.
$workspace_id = reset($workspace_ids);
$workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
$this->context->buildViolation($constraint->message)
->setParameter('%label', $workspace->label())
->addViolation();
}
}
}
}

View file

@ -0,0 +1,422 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\views\Plugin\views\query\QueryPluginBase;
use Drupal\views\Plugin\views\query\Sql;
use Drupal\views\Plugin\ViewsHandlerManager;
use Drupal\views\ViewExecutable;
use Drupal\views\ViewsData;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for altering views queries.
*
* @internal
*/
class ViewsQueryAlter implements ContainerInjectionInterface {
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* The views data.
*
* @var \Drupal\views\ViewsData
*/
protected $viewsData;
/**
* A plugin manager which handles instances of views join plugins.
*
* @var \Drupal\views\Plugin\ViewsHandlerManager
*/
protected $viewsJoinPluginManager;
/**
* Constructs a new ViewsQueryAlter instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
* The entity field manager.
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
* @param \Drupal\views\ViewsData $views_data
* The views data.
* @param \Drupal\views\Plugin\ViewsHandlerManager $views_join_plugin_manager
* The views join plugin manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, WorkspaceManagerInterface $workspace_manager, ViewsData $views_data, ViewsHandlerManager $views_join_plugin_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->entityFieldManager = $entity_field_manager;
$this->workspaceManager = $workspace_manager;
$this->viewsData = $views_data;
$this->viewsJoinPluginManager = $views_join_plugin_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('entity_field.manager'),
$container->get('workspaces.manager'),
$container->get('views.views_data'),
$container->get('plugin.manager.views.join')
);
}
/**
* Implements a hook bridge for hook_views_query_alter().
*
* @see hook_views_query_alter()
*/
public function alterQuery(ViewExecutable $view, QueryPluginBase $query) {
// Don't alter any views queries if we're in the default workspace.
if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
return;
}
// Don't alter any non-sql views queries.
if (!$query instanceof Sql) {
return;
}
// Find out what entity types are represented in this query.
$entity_type_ids = [];
foreach ($query->relationships as $info) {
$table_data = $this->viewsData->get($info['base']);
if (empty($table_data['table']['entity type'])) {
continue;
}
$entity_type_id = $table_data['table']['entity type'];
// This construct ensures each entity type exists only once.
$entity_type_ids[$entity_type_id] = $entity_type_id;
}
$entity_type_definitions = $this->entityTypeManager->getDefinitions();
foreach ($entity_type_ids as $entity_type_id) {
if ($this->workspaceManager->isEntityTypeSupported($entity_type_definitions[$entity_type_id])) {
$this->alterQueryForEntityType($query, $entity_type_definitions[$entity_type_id]);
}
}
}
/**
* Alters the entity type tables for a Views query.
*
* This should only be called after determining that this entity type is
* involved in the query, and that a non-default workspace is in use.
*
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
*/
protected function alterQueryForEntityType(Sql $query, EntityTypeInterface $entity_type) {
/** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
$table_mapping = $this->entityTypeManager->getStorage($entity_type->id())->getTableMapping();
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
$dedicated_field_storage_definitions = array_filter($field_storage_definitions, function ($definition) use ($table_mapping) {
return $table_mapping->requiresDedicatedTableStorage($definition);
});
$dedicated_field_data_tables = array_map(function ($definition) use ($table_mapping) {
return $table_mapping->getDedicatedDataTableName($definition);
}, $dedicated_field_storage_definitions);
$move_workspace_tables = [];
$table_queue =& $query->getTableQueue();
foreach ($table_queue as $alias => &$table_info) {
// If we reach the workspace_association array item before any candidates,
// then we do not need to move it.
if ($table_info['table'] == 'workspace_association') {
break;
}
// Any dedicated field table is a candidate.
if ($field_name = array_search($table_info['table'], $dedicated_field_data_tables, TRUE)) {
$relationship = $table_info['relationship'];
// There can be reverse relationships used. If so, Workspaces can't do
// anything with them. Detect this and skip.
if ($table_info['join']->field != 'entity_id') {
continue;
}
// Get the dedicated revision table name.
$new_table_name = $table_mapping->getDedicatedRevisionTableName($field_storage_definitions[$field_name]);
// Now add the workspace_association table.
$workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
// Update the join to use our COALESCE.
$revision_field = $entity_type->getKey('revision');
$table_info['join']->leftTable = NULL;
$table_info['join']->leftField = "COALESCE($workspace_association_table.target_entity_revision_id, $relationship.$revision_field)";
// Update the join and the table info to our new table name, and to join
// on the revision key.
$table_info['table'] = $new_table_name;
$table_info['join']->table = $new_table_name;
$table_info['join']->field = 'revision_id';
// Finally, if we added the workspace_association table we have to move
// it in the table queue so that it comes before this field.
if (empty($move_workspace_tables[$workspace_association_table])) {
$move_workspace_tables[$workspace_association_table] = $alias;
}
}
}
// JOINs must be in order. i.e, any tables you mention in the ON clause of a
// JOIN must appear prior to that JOIN. Since we're modifying a JOIN in
// place, and adding a new table, we must ensure that the new table appears
// prior to this one. So we recorded at what index we saw that table, and
// then use array_splice() to move the workspace_association table join to
// the correct position.
foreach ($move_workspace_tables as $workspace_association_table => $alias) {
$this->moveEntityTable($query, $workspace_association_table, $alias);
}
$base_entity_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable();
$base_fields = array_diff($table_mapping->getFieldNames($entity_type->getBaseTable()), [$entity_type->getKey('langcode')]);
$revisionable_fields = array_diff($table_mapping->getFieldNames($entity_type->getRevisionDataTable()), $base_fields);
// Go through and look to see if we have to modify fields and filters.
foreach ($query->fields as &$field_info) {
// Some fields don't actually have tables, meaning they're formulae and
// whatnot. At this time we are going to ignore those.
if (empty($field_info['table'])) {
continue;
}
// Dereference the alias into the actual table.
$table = $table_queue[$field_info['table']]['table'];
if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
$relationship = $table_queue[$field_info['table']]['alias'];
$alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
if ($alias) {
// Change the base table to use the revision table instead.
$field_info['table'] = $alias;
}
}
}
$relationships = [];
// Build a list of all relationships that might be for our table.
foreach ($query->relationships as $relationship => $info) {
if ($info['base'] == $base_entity_table) {
$relationships[] = $relationship;
}
}
// Now we have to go through our where clauses and modify any of our fields.
foreach ($query->where as &$clauses) {
foreach ($clauses['conditions'] as &$where_info) {
// Build a matrix of our possible relationships against fields we need
// to switch.
foreach ($relationships as $relationship) {
foreach ($revisionable_fields as $field) {
if (is_string($where_info['field']) && $where_info['field'] == "$relationship.$field") {
$alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
if ($alias) {
// Change the base table to use the revision table instead.
$where_info['field'] = "$alias.$field";
}
}
}
}
}
}
// @todo Handle $query->orderby, $query->groupby, $query->having and
// $query->count_field in https://www.drupal.org/node/2968165.
}
/**
* Adds the 'workspace_association' table to a views query.
*
* @param string $entity_type_id
* The ID of the entity type to join.
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param string $relationship
* The primary table alias this table is related to.
*
* @return string
* The alias of the 'workspace_association' table.
*/
protected function ensureWorkspaceAssociationTable($entity_type_id, Sql $query, $relationship) {
if (isset($query->tables[$relationship]['workspace_association'])) {
return $query->tables[$relationship]['workspace_association']['alias'];
}
$table_data = $this->viewsData->get($query->relationships[$relationship]['base']);
// Construct the join.
$definition = [
'table' => 'workspace_association',
'field' => 'target_entity_id',
'left_table' => $relationship,
'left_field' => $table_data['table']['base']['field'],
'extra' => [
[
'field' => 'target_entity_type_id',
'value' => $entity_type_id,
],
[
'field' => 'workspace',
'value' => $this->workspaceManager->getActiveWorkspace()->id(),
],
],
'type' => 'LEFT',
];
$join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
$join->adjusted = TRUE;
return $query->queueTable('workspace_association', $relationship, $join);
}
/**
* Adds the revision table of an entity type to a query object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\views\Plugin\views\query\Sql $query
* The query plugin object for the query.
* @param string $relationship
* The name of the relationship.
*
* @return string
* The alias of the relationship.
*/
protected function ensureRevisionTable(EntityTypeInterface $entity_type, Sql $query, $relationship) {
// Get the alias for the 'workspace_association' table we chain off of in
// the COALESCE.
$workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
// Get the name of the revision table and revision key.
$base_revision_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
$revision_field = $entity_type->getKey('revision');
// If the table was already added and has a join against the same field on
// the revision table, reuse that rather than adding a new join.
if (isset($query->tables[$relationship][$base_revision_table])) {
$table_queue =& $query->getTableQueue();
$alias = $query->tables[$relationship][$base_revision_table]['alias'];
if (isset($table_queue[$alias]['join']->field) && $table_queue[$alias]['join']->field == $revision_field) {
// If this table previously existed, but was not added by us, we need
// to modify the join and make sure that 'workspace_association' comes
// first.
if (empty($table_queue[$alias]['join']->workspace_adjusted)) {
$table_queue[$alias]['join'] = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
// We also have to ensure that our 'workspace_association' comes before
// this.
$this->moveEntityTable($query, $workspace_association_table, $alias);
}
return $alias;
}
}
// Construct a new join.
$join = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
return $query->queueTable($base_revision_table, $relationship, $join);
}
/**
* Fetches a join for a revision table using the workspace_association table.
*
* @param string $relationship
* The relationship to use in the view.
* @param string $table
* The table name.
* @param string $field
* The field to join on.
* @param string $workspace_association_table
* The alias of the 'workspace_association' table joined to the main entity
* table.
*
* @return \Drupal\views\Plugin\views\join\JoinPluginInterface
* An adjusted views join object to add to the query.
*/
protected function getRevisionTableJoin($relationship, $table, $field, $workspace_association_table) {
$definition = [
'table' => $table,
'field' => $field,
// Making this explicitly null allows the left table to be a formula.
'left_table' => NULL,
'left_field' => "COALESCE($workspace_association_table.target_entity_revision_id, $relationship.$field)",
];
/** @var \Drupal\views\Plugin\views\join\JoinPluginInterface $join */
$join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
$join->adjusted = TRUE;
$join->workspace_adjusted = TRUE;
return $join;
}
/**
* Moves a 'workspace_association' table to appear before the given alias.
*
* Because Workspace chains possibly pre-existing tables onto the
* 'workspace_association' table, we have to ensure that the
* 'workspace_association' table appears in the query before the alias it's
* chained on or the SQL is invalid.
*
* @param \Drupal\views\Plugin\views\query\Sql $query
* The SQL query object.
* @param string $workspace_association_table
* The alias of the 'workspace_association' table.
* @param string $alias
* The alias of the table it needs to appear before.
*/
protected function moveEntityTable(Sql $query, $workspace_association_table, $alias) {
$table_queue =& $query->getTableQueue();
$keys = array_keys($table_queue);
$current_index = array_search($workspace_association_table, $keys);
$index = array_search($alias, $keys);
// If it's already before our table, we don't need to move it, as we could
// accidentally move it forward.
if ($current_index < $index) {
return;
}
$splice = [$workspace_association_table => $table_queue[$workspace_association_table]];
unset($table_queue[$workspace_association_table]);
// Now move the item to the proper location in the array. Don't use
// array_splice() because that breaks indices.
$table_queue = array_slice($table_queue, 0, $index, TRUE) +
$splice +
array_slice($table_queue, $index, NULL, TRUE);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the access control handler for the workspace entity type.
*
* @see \Drupal\workspaces\Entity\Workspace
*/
class WorkspaceAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
if ($operation === 'delete' && $entity->isDefaultWorkspace()) {
return AccessResult::forbidden()->addCacheableDependency($entity);
}
if ($account->hasPermission('administer workspaces')) {
return AccessResult::allowed()->cachePerPermissions();
}
// The default workspace is always viewable, no matter what.
if ($operation == 'view' && $entity->isDefaultWorkspace()) {
return AccessResult::allowed()->addCacheableDependency($entity);
}
$permission_operation = $operation === 'update' ? 'edit' : $operation;
// Check if the user has permission to access all workspaces.
$access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' any workspace');
// Check if it's their own workspace, and they have permission to access
// their own workspace.
if ($access_result->isNeutral() && $account->isAuthenticated() && $account->id() === $entity->getOwnerId()) {
$access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' own workspace')
->cachePerUser()
->addCacheableDependency($entity);
}
return $access_result;
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
return AccessResult::allowedIfHasPermission($account, 'create workspace');
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Access\AccessException;
/**
* Exception thrown when trying to switch to an inaccessible workspace.
*/
class WorkspaceAccessException extends AccessException {
}

View file

@ -0,0 +1,59 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
/**
* Defines the storage handler class for the Workspace association entity type.
*/
class WorkspaceAssociationStorage extends SqlContentEntityStorage implements WorkspaceAssociationStorageInterface {
/**
* {@inheritdoc}
*/
public function postPush(WorkspaceInterface $workspace) {
$this->database
->delete($this->entityType->getBaseTable())
->condition('workspace', $workspace->id())
->execute();
$this->database
->delete($this->entityType->getRevisionTable())
->condition('workspace', $workspace->id())
->execute();
}
/**
* {@inheritdoc}
*/
public function getTrackedEntities($workspace_id, $all_revisions = FALSE) {
$table = $all_revisions ? $this->getRevisionTable() : $this->getBaseTable();
$query = $this->database->select($table, 'base_table');
$query
->fields('base_table', ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id'])
->orderBy('target_entity_revision_id', 'ASC')
->condition('workspace', $workspace_id);
$tracked_revisions = [];
foreach ($query->execute() as $record) {
$tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id;
}
return $tracked_revisions;
}
/**
* {@inheritdoc}
*/
public function getEntityTrackingWorkspaceIds(EntityInterface $entity) {
$query = $this->database->select($this->getBaseTable(), 'base_table');
$query
->fields('base_table', ['workspace'])
->condition('target_entity_type_id', $entity->getEntityTypeId())
->condition('target_entity_id', $entity->id());
return $query->execute()->fetchCol();
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Defines an interface for workspace association entity storage classes.
*/
interface WorkspaceAssociationStorageInterface extends ContentEntityStorageInterface {
/**
* Triggers clean-up operations after pushing.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* A workspace entity.
*/
public function postPush(WorkspaceInterface $workspace);
/**
* Retrieves the content revisions tracked by a given workspace.
*
* @param string $workspace_id
* The ID of the workspace.
* @param bool $all_revisions
* (optional) Whether to return all the tracked revisions for each entity or
* just the latest tracked revision. Defaults to FALSE.
*
* @return array
* Returns a multidimensional array where the first level keys are entity
* type IDs and the values are an array of entity IDs keyed by revision IDs.
*/
public function getTrackedEntities($workspace_id, $all_revisions = FALSE);
/**
* Gets a list of workspace IDs in which an entity is tracked.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* An entity object.
*
* @return string[]
* An array of workspace IDs where the given entity is tracked, or an empty
* array if it is not tracked anywhere.
*/
public function getEntityTrackingWorkspaceIds(EntityInterface $entity);
}

View file

@ -0,0 +1,57 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CacheContextInterface;
/**
* Defines the WorkspaceCacheContext service, for "per workspace" caching.
*
* Cache context ID: 'workspace'.
*/
class WorkspaceCacheContext implements CacheContextInterface {
/**
* The workspace manager.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new WorkspaceCacheContext service.
*
* @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function getLabel() {
return t('Workspace');
}
/**
* {@inheritdoc}
*/
public function getContext() {
return $this->workspaceManager->getActiveWorkspace()->id();
}
/**
* {@inheritdoc}
*/
public function getCacheableMetadata($type = NULL) {
// The active workspace will always be stored in the user's session.
$cacheability = new CacheableMetadata();
$cacheability->addCacheContexts(['session']);
return $cacheability;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Drupal\workspaces;
/**
* An exception thrown when two workspaces are in a conflicting content state.
*/
class WorkspaceConflictException extends \RuntimeException {
}

View file

@ -0,0 +1,50 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\user\EntityOwnerInterface;
/**
* Defines an interface for the workspace entity type.
*/
interface WorkspaceInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
/**
* The ID of the default workspace.
*/
const DEFAULT_WORKSPACE = 'live';
/**
* Publishes the contents of this workspace to the default (Live) workspace.
*/
public function publish();
/**
* Determines whether the workspace is the default one or not.
*
* @return bool
* TRUE if this workspace is the default one (e.g 'Live'), FALSE otherwise.
*/
public function isDefaultWorkspace();
/**
* Gets the workspace creation timestamp.
*
* @return int
* Creation timestamp of the workspace.
*/
public function getCreatedTime();
/**
* Sets the workspace creation timestamp.
*
* @param int $timestamp
* The workspace creation timestamp.
*
* @return $this
*/
public function setCreatedTime($timestamp);
}

View file

@ -0,0 +1,240 @@
<?php
namespace Drupal\workspaces;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Ajax\AjaxHelperTrait;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class to build a listing of workspace entities.
*
* @see \Drupal\workspaces\Entity\Workspace
*/
class WorkspaceListBuilder extends EntityListBuilder {
use AjaxHelperTrait;
/**
* The workspace manager service.
*
* @var \Drupal\workspaces\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* 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\workspaces\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager) {
parent::__construct($entity_type, $storage);
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity.manager')->getStorage($entity_type->id()),
$container->get('workspaces.manager')
);
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Workspace');
$header['uid'] = $this->t('Owner');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
$row['data'] = [
'label' => $entity->label(),
'owner' => $entity->getOwner()->getDisplayname(),
];
$row['data'] = $row['data'] + parent::buildRow($entity);
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($entity->id() === $active_workspace->id()) {
$row['class'] = 'active-workspace';
}
return $row;
}
/**
* {@inheritdoc}
*/
public function getDefaultOperations(EntityInterface $entity) {
/** @var \Drupal\workspaces\WorkspaceInterface $entity */
$operations = parent::getDefaultOperations($entity);
if (isset($operations['edit'])) {
$operations['edit']['query']['destination'] = $entity->toUrl('collection')->toString();
}
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if ($entity->id() != $active_workspace->id()) {
$operations['activate'] = [
'title' => $this->t('Switch to @workspace', ['@workspace' => $entity->label()]),
// Use a weight lower than the one of the 'Edit' operation because we
// want the 'Activate' operation to be the primary operation.
'weight' => 0,
'url' => $entity->toUrl('activate-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
];
}
if (!$entity->isDefaultWorkspace()) {
$operations['deploy'] = [
'title' => $this->t('Deploy content'),
// The 'Deploy' operation should be the default one for the currently
// active workspace.
'weight' => ($entity->id() == $active_workspace->id()) ? 0 : 20,
'url' => $entity->toUrl('deploy-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
];
}
return $operations;
}
/**
* {@inheritdoc}
*/
public function load() {
$entities = parent::load();
// Make the active workspace more visible by moving it first in the list.
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$entities = [$active_workspace->id() => $entities[$active_workspace->id()]] + $entities;
return $entities;
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
if ($this->isAjax()) {
$this->offCanvasRender($build);
}
else {
$build['#attached'] = [
'library' => ['workspaces/drupal.workspaces.overview'],
];
}
return $build;
}
/**
* Renders the off canvas elements.
*
* @param array $build
* A render array.
*/
protected function offCanvasRender(array &$build) {
$active_workspace = $this->workspaceManager->getActiveWorkspace();
$row_count = count($build['table']['#rows']);
$build['active_workspace'] = [
'#type' => 'container',
'#weight' => -20,
'#attributes' => [
'class' => [
'active-workspace',
$active_workspace->isDefaultWorkspace() ? 'active-workspace--default' : 'active-workspace--not-default',
'active-workspace--' . $active_workspace->id(),
],
],
'label' => [
'#type' => 'label',
'#prefix' => '<div class="active-workspace__title">' . $this->t('Current workspace:') . '</div>',
'#title' => $active_workspace->label(),
'#title_display' => '',
'#attributes' => ['class' => 'active-workspace__label'],
],
'manage' => [
'#type' => 'link',
'#title' => $this->t('Manage workspaces'),
'#url' => $active_workspace->toUrl('collection'),
'#attributes' => [
'class' => ['active-workspace__manage'],
],
],
];
if (!$active_workspace->isDefaultWorkspace()) {
$build['active_workspace']['actions'] = [
'#type' => 'container',
'#weight' => 20,
'#attributes' => [
'class' => ['active-workspace__actions'],
],
'deploy' => [
'#type' => 'link',
'#title' => $this->t('Deploy content'),
'#url' => $active_workspace->toUrl('deploy-form', ['query' => ['destination' => $active_workspace->toUrl('collection')->toString()]]),
'#attributes' => [
'class' => ['button', 'active-workspace__button'],
],
],
];
}
if ($row_count > 2) {
$build['all_workspaces'] = [
'#type' => 'link',
'#title' => $this->t('View all @count workspaces', ['@count' => $row_count]),
'#url' => $active_workspace->toUrl('collection'),
'#attributes' => [
'class' => ['all-workspaces'],
],
];
}
$items = [];
$rows = array_slice($build['table']['#rows'], 0, 5, TRUE);
foreach ($rows as $id => $row) {
if ($active_workspace->id() !== $id) {
$url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $id]);
$default_class = $id === WorkspaceInterface::DEFAULT_WORKSPACE ? 'workspaces__item--default' : 'workspaces__item--not-default';
$items[] = [
'#type' => 'link',
'#title' => $row['data']['label'],
'#url' => $url,
'#attributes' => [
'class' => ['use-ajax', 'workspaces__item', $default_class],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'width' => 500,
]),
],
];
}
}
$build['workspaces'] = [
'#theme' => 'item_list',
'#items' => $items,
'#wrapper_attributes' => ['class' => ['workspaces']],
'#cache' => [
'contexts' => $this->entityType->getListCacheContexts(),
'tags' => $this->entityType->getListCacheTags(),
],
];
unset($build['table']);
unset($build['pager']);
}
}

View file

@ -0,0 +1,283 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Provides the workspace manager.
*/
class WorkspaceManager implements WorkspaceManagerInterface {
use StringTranslationTrait;
/**
* An array of entity type IDs that can not belong to a workspace.
*
* By default, only entity types which are revisionable and publishable can
* belong to a workspace.
*
* @var string[]
*/
protected $blacklist = [
'workspace_association',
'workspace',
];
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The class resolver.
*
* @var \Drupal\Core\DependencyInjection\ClassResolverInterface
*/
protected $classResolver;
/**
* The workspace negotiator service IDs.
*
* @var array
*/
protected $negotiatorIds;
/**
* The current active workspace.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $activeWorkspace;
/**
* Constructs a new WorkspaceManager.
*
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Session\AccountProxyInterface $current_user
* The current user.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
* @param array $negotiator_ids
* The workspace negotiator service IDs.
*/
public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) {
$this->requestStack = $request_stack;
$this->entityTypeManager = $entity_type_manager;
$this->currentUser = $current_user;
$this->state = $state;
$this->logger = $logger;
$this->classResolver = $class_resolver;
$this->negotiatorIds = $negotiator_ids;
}
/**
* {@inheritdoc}
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type) {
if (!isset($this->blacklist[$entity_type->id()])
&& $entity_type->entityClassImplements(EntityPublishedInterface::class)
&& $entity_type->isRevisionable()) {
return TRUE;
}
$this->blacklist[$entity_type->id()] = $entity_type->id();
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getSupportedEntityTypes() {
$entity_types = [];
foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
if ($this->isEntityTypeSupported($entity_type)) {
$entity_types[$entity_type_id] = $entity_type;
}
}
return $entity_types;
}
/**
* {@inheritdoc}
*/
public function getActiveWorkspace() {
if (!isset($this->activeWorkspace)) {
$request = $this->requestStack->getCurrentRequest();
foreach ($this->negotiatorIds as $negotiator_id) {
$negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
if ($negotiator->applies($request)) {
if ($this->activeWorkspace = $negotiator->getActiveWorkspace($request)) {
break;
}
}
}
}
// The default workspace negotiator always returns a valid workspace.
return $this->activeWorkspace;
}
/**
* {@inheritdoc}
*/
public function setActiveWorkspace(WorkspaceInterface $workspace) {
// If the current user doesn't have access to view the workspace, they
// shouldn't be allowed to switch to it.
if (!$workspace->access('view') && !$workspace->isDefaultWorkspace()) {
$this->logger->error('Denied access to view workspace %workspace_label for user %uid', [
'%workspace_label' => $workspace->label(),
'%uid' => $this->currentUser->id(),
]);
throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
}
$this->activeWorkspace = $workspace;
// Set the workspace on the proper negotiator.
$request = $this->requestStack->getCurrentRequest();
foreach ($this->negotiatorIds as $negotiator_id) {
$negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
if ($negotiator->applies($request)) {
$negotiator->setActiveWorkspace($workspace);
break;
}
}
$supported_entity_types = $this->getSupportedEntityTypes();
foreach ($supported_entity_types as $supported_entity_type) {
$this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache();
}
return $this;
}
/**
* {@inheritdoc}
*/
public function shouldAlterOperations(EntityTypeInterface $entity_type) {
return $this->isEntityTypeSupported($entity_type) && !$this->getActiveWorkspace()->isDefaultWorkspace();
}
/**
* {@inheritdoc}
*/
public function purgeDeletedWorkspacesBatch() {
$deleted_workspace_ids = $this->state->get('workspace.deleted', []);
// Bail out early if there are no workspaces to purge.
if (empty($deleted_workspace_ids)) {
return;
}
$batch_size = Settings::get('entity_update_batch_size', 50);
/** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */
$workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
// Get the first deleted workspace from the list and delete the revisions
// associated with it, along with the workspace_association entries.
$workspace_id = reset($deleted_workspace_ids);
$workspace_association_ids = $this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size);
if ($workspace_association_ids) {
$workspace_associations = $workspace_association_storage->loadMultipleRevisions(array_keys($workspace_association_ids));
foreach ($workspace_associations as $workspace_association) {
$associated_entity_storage = $this->entityTypeManager->getStorage($workspace_association->target_entity_type_id->value);
// Delete the associated entity revision.
if ($entity = $associated_entity_storage->loadRevision($workspace_association->target_entity_revision_id->value)) {
if ($entity->isDefaultRevision()) {
$entity->delete();
}
else {
$associated_entity_storage->deleteRevision($workspace_association->target_entity_revision_id->value);
}
}
// Delete the workspace_association revision.
if ($workspace_association->isDefaultRevision()) {
$workspace_association->delete();
}
else {
$workspace_association_storage->deleteRevision($workspace_association->getRevisionId());
}
}
}
// The purging operation above might have taken a long time, so we need to
// request a fresh list of workspace association IDs. If it is empty, we can
// go ahead and remove the deleted workspace ID entry from state.
if (!$this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size)) {
unset($deleted_workspace_ids[$workspace_id]);
$this->state->set('workspace.deleted', $deleted_workspace_ids);
}
}
/**
* Gets a list of workspace association IDs to purge.
*
* @param string $workspace_id
* The ID of the workspace.
* @param int $batch_size
* The maximum number of records that will be purged.
*
* @return array
* An array of workspace association IDs, keyed by their revision IDs.
*/
protected function getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size) {
return $this->entityTypeManager->getStorage('workspace_association')
->getQuery()
->allRevisions()
->accessCheck(FALSE)
->condition('workspace', $workspace_id)
->sort('revision_id', 'ASC')
->range(0, $batch_size)
->execute();
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an interface for managing Workspaces.
*/
interface WorkspaceManagerInterface {
/**
* Returns whether an entity type can belong to a workspace or not.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if the entity type can belong to a workspace, FALSE otherwise.
*/
public function isEntityTypeSupported(EntityTypeInterface $entity_type);
/**
* Returns an array of entity types that can belong to workspaces.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* The entity types what can belong to workspaces.
*/
public function getSupportedEntityTypes();
/**
* Gets the active workspace.
*
* @return \Drupal\workspaces\WorkspaceInterface
* The active workspace entity object.
*/
public function getActiveWorkspace();
/**
* Sets the active workspace via the workspace negotiators.
*
* @param \Drupal\workspaces\WorkspaceInterface $workspace
* The workspace to set as active.
*
* @return $this
*
* @throws \Drupal\workspaces\WorkspaceAccessException
* Thrown when the current user doesn't have access to view the workspace.
*/
public function setActiveWorkspace(WorkspaceInterface $workspace);
/**
* Determines whether runtime entity operations should be altered.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type to check.
*
* @return bool
* TRUE if the entity operations or queries should be altered in the current
* request, FALSE otherwise.
*/
public function shouldAlterOperations(EntityTypeInterface $entity_type);
}

View file

@ -0,0 +1,58 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Defines a factory class for workspace operations.
*
* @see \Drupal\workspaces\WorkspaceOperationInterface
* @see \Drupal\workspaces\WorkspacePublisherInterface
*
* @internal
*/
class WorkspaceOperationFactory {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* Constructs a new WorkspacePublisher.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database) {
$this->entityTypeManager = $entity_type_manager;
$this->database = $database;
}
/**
* Gets the workspace publisher.
*
* @param \Drupal\workspaces\WorkspaceInterface $source
* A workspace entity.
*
* @return \Drupal\workspaces\WorkspacePublisherInterface
* A workspace publisher object.
*/
public function getPublisher(WorkspaceInterface $source) {
return new WorkspacePublisher($this->entityTypeManager, $this->database, $source);
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace Drupal\workspaces;
/**
* Defines an interface for workspace operations.
*
* Example operations are publishing, merging and syncing with a remote
* workspace.
*
* @internal
*/
interface WorkspaceOperationInterface {
/**
* Returns the human-readable label of the source.
*
* @return string
* The source label.
*/
public function getSourceLabel();
/**
* Returns the human-readable label of the target.
*
* @return string
* The target label.
*/
public function getTargetLabel();
/**
* Checks if there are any conflicts between the source and the target.
*
* @return array
* Returns an array consisting of the number of conflicts between the source
* and the target, keyed by the conflict type constant.
*/
public function checkConflictsOnTarget();
/**
* Gets the revision identifiers for items which have changed on the target.
*
* @return array
* A multidimensional array of revision identifiers, keyed by entity type
* IDs.
*/
public function getDifferringRevisionIdsOnTarget();
/**
* Gets the revision identifiers for items which have changed on the source.
*
* @return array
* A multidimensional array of revision identifiers, keyed by entity type
* IDs.
*/
public function getDifferringRevisionIdsOnSource();
/**
* Gets the total number of items which have changed on the target.
*
* This returns the aggregated changes count across all entity types.
* For example, if two nodes and one taxonomy term have changed on the target,
* the return value is 3.
*
* @return int
* The number of differing revisions.
*/
public function getNumberOfChangesOnTarget();
/**
* Gets the total number of items which have changed on the source.
*
* This returns the aggregated changes count across all entity types.
* For example, if two nodes and one taxonomy term have changed on the source,
* the return value is 3.
*
* @return int
* The number of differing revisions.
*/
public function getNumberOfChangesOnSource();
}

View file

@ -0,0 +1,183 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Default implementation of the workspace publisher.
*
* @internal
*/
class WorkspacePublisher implements WorkspacePublisherInterface {
/**
* The source workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $sourceWorkspace;
/**
* The target workspace entity.
*
* @var \Drupal\workspaces\WorkspaceInterface
*/
protected $targetWorkspace;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The workspace association storage.
*
* @var \Drupal\workspaces\WorkspaceAssociationStorageInterface
*/
protected $workspaceAssociationStorage;
/**
* Constructs a new WorkspacePublisher.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceInterface $source) {
$this->entityTypeManager = $entity_type_manager;
$this->database = $database;
$this->workspaceAssociationStorage = $entity_type_manager->getStorage('workspace_association');
$this->sourceWorkspace = $source;
$this->targetWorkspace = $this->entityTypeManager->getStorage('workspace')->load(WorkspaceInterface::DEFAULT_WORKSPACE);
}
/**
* {@inheritdoc}
*/
public function publish() {
if ($this->checkConflictsOnTarget()) {
throw new WorkspaceConflictException();
}
$transaction = $this->database->startTransaction();
try {
// @todo Handle the publishing of a workspace with a batch operation in
// https://www.drupal.org/node/2958752.
foreach ($this->getDifferringRevisionIdsOnSource() as $entity_type_id => $revision_difference) {
$entity_revisions = $this->entityTypeManager->getStorage($entity_type_id)
->loadMultipleRevisions(array_keys($revision_difference));
/** @var \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity */
foreach ($entity_revisions as $entity) {
// When pushing workspace-specific revisions to the default workspace
// (Live), we simply need to mark them as default revisions.
// @todo Remove this dynamic property once we have an API for
// associating temporary data with an entity:
// https://www.drupal.org/node/2896474.
$entity->_isReplicating = TRUE;
$entity->isDefaultRevision(TRUE);
$entity->save();
}
}
}
catch (\Exception $e) {
$transaction->rollBack();
watchdog_exception('workspaces', $e);
throw $e;
}
// Notify the workspace association storage that a workspace has been
// pushed.
$this->workspaceAssociationStorage->postPush($this->sourceWorkspace);
}
/**
* {@inheritdoc}
*/
public function getSourceLabel() {
return $this->sourceWorkspace->label();
}
/**
* {@inheritdoc}
*/
public function getTargetLabel() {
return $this->targetWorkspace->label();
}
/**
* {@inheritdoc}
*/
public function checkConflictsOnTarget() {
// Nothing to do for now, we can not get to a conflicting state because an
// entity which is being edited in a workspace can not be edited in any
// other workspace.
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnTarget() {
$target_revision_difference = [];
$tracked_entities = $this->workspaceAssociationStorage->getTrackedEntities($this->sourceWorkspace->id());
foreach ($tracked_entities as $entity_type_id => $tracked_revisions) {
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
// Get the latest revision IDs for all the entities that are tracked by
// the source workspace.
$query = $this->entityTypeManager
->getStorage($entity_type_id)
->getQuery()
->condition($entity_type->getKey('id'), $tracked_revisions, 'IN')
->latestRevision();
$result = $query->execute();
// Now we compare the revision IDs which are tracked by the source
// workspace to the latest revision IDs of those entities and the
// difference between these two arrays gives us all the entities which
// have been modified on the target.
if ($revision_difference = array_diff_key($result, $tracked_revisions)) {
$target_revision_difference[$entity_type_id] = $revision_difference;
}
}
return $target_revision_difference;
}
/**
* {@inheritdoc}
*/
public function getDifferringRevisionIdsOnSource() {
// Get the Workspace association revisions which haven't been pushed yet.
return $this->workspaceAssociationStorage->getTrackedEntities($this->sourceWorkspace->id());
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnTarget() {
$total_changes = $this->getDifferringRevisionIdsOnTarget();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
/**
* {@inheritdoc}
*/
public function getNumberOfChangesOnSource() {
$total_changes = $this->getDifferringRevisionIdsOnSource();
return count($total_changes, COUNT_RECURSIVE) - count($total_changes);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Drupal\workspaces;
/**
* Defines an interface for the workspace publisher.
*
* @internal
*/
interface WorkspacePublisherInterface extends WorkspaceOperationInterface {
/**
* Publishes the contents of a workspace to the default (Live) workspace.
*/
public function publish();
}

View file

@ -0,0 +1,23 @@
<?php
namespace Drupal\workspaces;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
/**
* Defines a service provider for the Workspaces module.
*/
class WorkspacesServiceProvider extends ServiceProviderBase {
/**
* {@inheritdoc}
*/
public function alter(ContainerBuilder $container) {
// Add the 'workspace' cache context as required.
$renderer_config = $container->getParameter('renderer.config');
$renderer_config['required_cache_contexts'][] = 'workspace';
$container->setParameter('renderer.config', $renderer_config);
}
}