Problem/Motivation

While using basic workspace functionality extensively at Pfizer we noticed that our editorial staff found it cumbersome to work on sites with lots of "living" content. Content would constantly have to be "pulled down" to draft workspaces in order to do full-site preview.

The solution is content inheritance between hierarchical workspaces by introducing the concept of sub-workspaces! The ability of having hierarchical workspaces has existed in the contrib Workspace module for a while, but content inheritance is new with this core patch.

Not only does this patch substantially improve the editorial experience of workspace sites, the functionality can enable many other very interesting things;

  • Entirely new editorial workflows are possible – sub campaign workspaces, regional or country workspaces that inherit content, etc.
  • Contrib can expose a JSON API per workspace and you suddenly have an incredibly competent content-hub solution!
  • Domain module anyone?

Demo videos in comment #33.

Proposed resolution

Add a parent field to workspace entities, and track each workspace-specific revision in all its descendants.

Given the following workspace hierarchy, here's what's allowed to do with each workspace:

Live
|-- Stage
|   |-- Dev
|   |   |-- Local 1
|   +   \-- Local 2
\-- QA
  • Only top-level workspaces can be published to Live (i.e Stage and QA)
  • Workspaces that have children can not be deleted (i.e. Stage and Dev)
  • Workspace that have a parent are allowed to merge their contents into their parent (i.e. Local 1 -> Dev, Local 2 -> Dev, Dev -> Stage)

Remaining tasks

Review, commit.

User interface changes

When adding a workspace, the user will be able to select a parent workspace.

API changes

Nope.

Data model changes

- a new entity reference field on the Workspace entity type
- the workspace_association index (from #3062434: Track the workspace of a revision in a base field and convert the workspace_association entity type to a custom index) will also track a workspace-specific revision in all its descendant workspaces

Release notes snippet

The Workspaces module now provides the ability to create sub-workspaces, which automatically inherit all the contents of their parent workspace, and enables various use-cases like: new editorial workflows (sub campaign workspaces, sub-workspaces per country/region, etc.), possible integrations with JSON:API or GraphQL to instantly provide a content-hub solution, and many others.

CommentFileSizeAuthor
#60 interdiff-60.txt20.89 KBamateescu
#60 3062486-60.patch90.38 KBamateescu
#56 3062486-56.patch108.96 KBamateescu
#55 interdiff-54.txt15.47 KBamateescu
#55 3062486-54.patch108.3 KBamateescu
#53 interdiff-53.txt36.97 KBamateescu
#53 3062486-53.patch102.16 KBamateescu
#50 interdiff-50.txt12.61 KBamateescu
#50 3062486-50.patch92.77 KBamateescu
#47 Workspaces___Drupal_8_x.jpg117.34 KBplach
#47 Would_you_like_to_merge_the_contents_of_the_Dev_workspace_into_Stage____Drupal_8_x.jpg75.11 KBplach
#44 interdiff-44.txt2.06 KBamateescu
#44 3062486-44.patch87.23 KBamateescu
#43 interdiff.txt2.4 KBdixon_
#41 interdiff-41.txt972 bytesamateescu
#41 3062486-41.patch86.85 KBamateescu
#39 interdiff-39.txt37.9 KBamateescu
#39 3062486-39.patch85.9 KBamateescu
#33 sub-workspaces-delete.mov7.39 MBdixon_
#33 sub-workspaces-change.mov11.54 MBdixon_
#33 sub-workspaces-demo.mov30.31 MBdixon_
#32 interdiff-32.txt14.97 KBamateescu
#32 3062486-32.patch59.66 KBamateescu
#28 interdiff-28.txt28.59 KBamateescu
#28 3062486-28.patch54.46 KBamateescu
#24 interdiff-24.txt559 bytesamateescu
#24 3062486-24.patch28.69 KBamateescu
#22 interdiff-22.txt6.58 KBamateescu
#22 3062486-22.patch28.67 KBamateescu
#21 interdiff-21.txt4.22 KBamateescu
#21 3062486-21.patch24.5 KBamateescu
#19 interdiff-19.txt8.54 KBamateescu
#19 3062486-19.patch23.26 KBamateescu
#18 3062486-18-combined-8.7.x.patch111.92 KBamateescu
#18 3062486-18-for-review.patch22.57 KBamateescu
#14 3062486-14-combined.patch129.08 KBblazey
#14 3062486-14-for-review.patch34.42 KBblazey
#14 interdiff-13-14.txt18.44 KBblazey
#13 interdiff-13.txt7.7 KBamateescu
#13 3062486-13-combined.patch87.14 KBamateescu
#13 3062486-13-for-review.patch23.62 KBamateescu
#12 3062486-12-8.7.3-backport.patch87.7 KBpmelab
#11 3062486-8.7.3-backport.patch63.21 KBpmelab
#10 3062486-7.drupal.Add-the-ability-to-create-subworkspaces-in-order-to-enable-the-dev--stage--live-workflow-for-content.patch87.59 KBpmelab
#9 interdiff.3062486.8-9.txt614 bytespmelab
#9 3062486-9.drupal.Add-the-ability-to-create-subworkspaces-in-order-to-enable-the-dev--stage--live-workflow-for-content.patch112.85 KBpmelab
#8 interdiff.3062486.7-8.txt26.08 KBpmelab
#8 3062486-8.drupal.Add-the-ability-to-create-subworkspaces-in-order-to-enable-the-dev--stage--live-workflow-for-content.patch112.25 KBpmelab
#7 interdiff.3062486.4-7.txt3.38 KBpmelab
#7 3062486-7.drupal.Add-the-ability-to-create-subworkspaces-in-order-to-enable-the-dev--stage--live-workflow-for-content.patch87.59 KBpmelab
#4 interdiff.3062486.7-4.txt23.86 KBpmelab
#4 3062486-4.drupal.Add-the-ability-to-create-subworkspaces-in-order-to-enable-the-dev--stage--live-workflow-for-content.patch86.79 KBpmelab
#2 3062486.patch21.86 KBamateescu
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

amateescu created an issue. See original summary.

amateescu’s picture

Status: Active » Needs work
FileSize
21.86 KB

Here's some initial work.

The patch needs to be rerolled (and parts of it rewritten) on top of #3062434: Track the workspace of a revision in a base field and convert the workspace_association entity type to a custom index, because I only realized we need to do that issue first while working on this :)

amateescu’s picture

Title: Add the ability to have sub-workspaces to enable the dev -> stage -> live workflow for contnet » Add the ability to create sub-workspaces in order to enable the dev -> stage -> live workflow for content
yogeshmpawar’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work
pmelab’s picture

pmelab’s picture

pmelab’s picture

FileSize
63.21 KB

Adding intermediate 8.7.3 backport patch.

pmelab’s picture

FileSize
87.7 KB

Fixed backport patch.

amateescu’s picture

Status: Needs work » Needs review
FileSize
23.62 KB
87.14 KB
7.7 KB

Excellent work in #4 to make this patch work on top of #3062434: Track the workspace of a revision in a base field and convert the workspace_association entity type to a custom index. Here's a few cosmetic fixes for now.

The interdiff is to #10.

blazey’s picture

Great effort on this one. I've reviewed the code, have done some testing and here are my findings.

>>> \Drupal\workspaces\Entity\Workspace::load('live')->label();
=> "Live"
>>> \Drupal\workspaces\Entity\Workspace::load('live')->parent->entity->label();
=> "Live"

The live workspace was its parent on a clean installation. This brought a few more issues. On this line in the WorkspaceStorage::loadTree()

$graph[$id]['edges'][$entity->parent->target_id] = TRUE;

a vertex from 'live' to 'live' was added which caused the graph to no longer be acyclic. This violated the input conditions of the Graph component and results in unexpected output

>>> $tree = \Drupal::entityTypeManager()->getStorage('workspace')->loadTree();
=> [
     "live" => Drupal\workspaces\Entity\Workspace {#4087},
     "stage" => Drupal\workspaces\Entity\Workspace {#4085},
   ]
>>> $tree['stage']->parent->entity->label();
=> "Live"
>>> $tree['stage']->_depth;
=> 0

The _depth property was zero regardless of the position in the tree.

Here's a summary of the changes introduced in this comment. This patch:

  1. Adds a generic NoSelfReference constraint for entity_reference fields that prevents an entity from referencing itself.
  2. Sets that constraint on the workspace parent field.
  3. Makes sure the root workspace never has a parent, even when it's created programmatically bypassing the validation, by setting it to null in preSave.
  4. Triggers a re-save of the existing default workspace in a post_update hook.
  5. Adds test coverage for the constraint and the root workspace parent field.
  6. Changes the output structure of WorkspaceStorage::loadTree to improve DX. The dynamic properties added to the full-blown objects were invisible in the debugger.
  7. Adds ::getAncestors(), ::getDescendants() and ::getDepth() to the workspace class and interface.
  8. Fixes the entry indentation on the Workspaces list page.
  9. Adds test coverage for the loadTree method.

The combined patch contains is based on the most recent patch in #3062434: Track the workspace of a revision in a base field and convert the workspace_association entity type to a custom index (#16).

amateescu’s picture

@blazey, I'm sorry but I have to push back a bit on most of the changes from #14:

Adds a generic NoSelfReference constraint for entity_reference fields that prevents an entity from referencing itself.

We already have an issue for this: #2280479: Add setting to EntityReferenceSelection plugins to prevent references to referencing entity, and I'd prefer not to try and sneak it into this one..

Makes sure the root workspace never has a parent, even when it's created programmatically bypassing the validation, by setting it to null in preSave.

For Drupal 8.8.0, I would really like to get #2935780: Remove the concept of a 'live' default workspace done, so we won't have to care about the root workspace anymore.

Adds ::getAncestors(), ::getDescendants() and ::getDepth() to the workspace class and interface.

The reason I didn't add these methods on WorkspaceInterface in the first place is that when we'll finally have a generic Tree API in core, we won't be able to re-use the methods names. We already have this problem with TypedDataInterface::getRoot() and TypedDataInterface::getParent(), which trickle their way up to the FieldItemList class, so I'd rather not make it worse.

Also, adding these methods on the main entity interface will make it very confusing when an entity will be able to be part of multiple hierarchies, for example the one proposed in #2725523: Add a revision_parent field to revisionable entities. When someone calls a method like this in code: $revision = $workspace_storage->loadRevision(5); $revision->getAncestors(), how could they know if they're getting the ancestors of the main entity object (the workspace tree), or the _revision_ ancestors of that revision (the revision tree of a specific workspace entity).

That's why I chose to make the _depth, _ancestors and _descendants properties as internal and as hidden as possible, and only document them in the loadTree() method from the storage :)

blazey’s picture

We had a discussion in slack with @amateescu and he said that none of the changes in the patch in #14 are worth pursuing, so reverting the display files back to the state from #13.

vijaycs85’s picture

Here is an initial review/questions at code level (didn't apply and check manually yet).

  1. +++ b/core/modules/workspaces/src/Plugin/EntityReferenceSelection/WorkspaceSelection.php
    @@ -0,0 +1,64 @@
    + *   id = "default:workspace",
    

    don't feel it matches with what this field is for?

  2. +++ b/core/modules/workspaces/src/Plugin/EntityReferenceSelection/WorkspaceSelection.php
    @@ -0,0 +1,64 @@
    +    $form['sort']['#access'] = FALSE;
    

    Do we have to do anything similar for $limit as well?

  3. +++ b/core/modules/workspaces/src/Plugin/EntityReferenceSelection/WorkspaceSelection.php
    @@ -0,0 +1,64 @@
    +    if ($match || $limit) {
    

    could we add a comment why we don't do anything special if $match or $limit set here?

  4. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -47,16 +47,59 @@ public function __construct(Connection $connection, EntityTypeManagerInterface $
    +    // Insert a new index entry for each workspace that is not tracking this
    +    // entity yet.
    

    It means, from the next revision update, these workspaces also considered tracking the entity?

amateescu’s picture

Rerolled the for-review patch to take into account the latest changes from #3062434: Track the workspace of a revision in a base field and convert the workspace_association entity type to a custom index and rolled a 8.7.x combined patch as well for those who need it :)

amateescu’s picture

Status: Needs review » Needs work

The last submitted patch, 19: 3062486-19.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review
FileSize
24.5 KB
4.22 KB

Fixed that test failure and the tree sorting.

amateescu’s picture

Did a thorough self-review after demonstrating this patch at a local Drupal meetup, and fixed a few things:

- only top-level workspaces can be published
- workspaces that have children can not be deleted
- don't show the 'parent' field on the workspace publish form

This should be ready for final reviews now :)

Status: Needs review » Needs work

The last submitted patch, 22: 3062486-22.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review
FileSize
28.69 KB
559 bytes

Fixed that test.

jeqq’s picture

Status: Needs review » Needs work

I've done some testing and it looks good in general.

My concerns:

  1. Currently there is not possible to deploy from a child workspace neither to parent or to Live workspace. The user is allowed to access the Deploy page, can see the items that can be deployed, but when tries to deploy them, is redirected to the list of workspaces and this error message is displayed: Only top-level workspaces can be published.. This is not a good user experience and after discussing with @amateescu, he said the plan is to make possible to deploy from child to parent workspaces. I think that would be the correct behavior.
  2. There should be added a test for the case when deleting a parent workspace.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

dixon_’s picture

1. Currently there is not possible to deploy from a child workspace neither to parent or to Live workspace. The user is allowed to access the Deploy page, can see the items that can be deployed, but when tries to deploy them, is redirected to the list of workspaces and this error message is displayed: Only top-level workspaces can be published.. This is not a good user experience and after discussing with @amateescu, he said the plan is to make possible to deploy from child to parent workspaces. I think that would be the correct behavior.

The issue that will address this is: #3070788: Add support for merging the contents of two workspaces

2. There should be added a test for the case when deleting a parent workspace.

Sounds indeed like good tests to add.

amateescu’s picture

Status: Needs work » Needs review
FileSize
54.46 KB
28.59 KB

Brought the patch from #3070788: Add support for merging the contents of two workspaces into this one and added tests for everything :)

amateescu’s picture

Issue summary: View changes

Updated the issue summary a bit.

Status: Needs review » Needs work

The last submitted patch, 28: 3062486-28.patch, failed testing. View results

dixon_’s picture

Andrei and I walked through the entire patch and did a whole round of manual testing and found a few things to tweak. I'll be back in a few hours to give this one another review and full end-to-end test.

amateescu’s picture

Status: Needs work » Needs review
FileSize
59.66 KB
14.97 KB

Tweaked those few things ;)

dixon_’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
30.31 MB
11.54 MB
7.39 MB

Thanks @amateescu! The patch looks good and functionality works as expected!

It's not trivial to describe or let alone imagine all possibilities this patch brings to Drupal 8! Content inheritance between workspaces is simply AMAZING! But if I try summarise this in a few bullet points;

  • Editorial experience is greatly enhanced for workspace sites that have a lot of "living" content. Instead of constantly having to "pull down" content into lower workspaces published content in higher workspace is now automagically inherited by lower workspaces.
  • Entirely new editorial workflows are possible – sub campaign workspaces, regional or country workspaces that inherit content, etc.
  • Expose a JSON API per workspace and you suddenly have an incredibly competent content-hub solution!
  • Domain module anyone?

Apart from the solid test coverage in this patch I have done a lot of manual testing and everything works as expected. Below are some demos of a few notable things.

Basic demo

In this first video I demo a basic workflow that shows the content inheritance from Live down through two campaign workspace ("Top campaign" and "Sub campaign"):

https://www.youtube.com/watch?v=AjKPC3PYXVk

Conflicting drafts demo

This is an important (but logical) gotcha. A node can only have unpublished drafts in a single workspace. The user is currently prevented in the UI to create conflicting drafts. This is the simplest and most logical solution until we get this into Drupal 9: #2725523: Add a revision_parent field to revisionable entities

https://www.youtube.com/watch?v=UBuF33zc0ts

Deleting content demo

While revision changes are contained in separate workspaces, deleting a node affects all workspaces. Ideally deleting a node in a sub-workspace would create a new "softly deleted" revision that should be merged and published upstream as any other revision change. However, that's to be considered separate from this issue as we need to extend the delete API with some significant changes in order to support this in Drupal 9. For background on this see: #2786135: WI: Phase E: Introduce Trash module

https://www.youtube.com/watch?v=waKELEDkKSI

dixon_’s picture

Issue summary: View changes
dixon_’s picture

Issue summary: View changes
dixon_’s picture

Status: Reviewed & tested by the community » Needs work

Just got off a call with @webchick where I walked her through the functionality end-to-end. She'll drop in her own comments later, but here's the main take-away from our conversation:

Webchick rightfully pointed out that deleting content in a workspace or sub-workspace which also wipes the content from all other workspaces is a no-go. Workspaces should be considered "a safe space" for making any changes and potentially destructive changes should not be allowed. I agree with this.

What we came up with is the following:

  • Content with a published revision in live can only be deleted in the live workspace
    • The user interface should not let the user take the delete action, i.e. disable the delete confirmation form and bulk operation forms
    • This would also make the behaviour kind of consistent with us already not allowing conflicting draft revisions in workspaces as we disable the content editing form in that case
  • Only content that's not been published to any other workspace should be able to be deleted in that very workspace
webchick’s picture

Wow, that's a great summary! :)

I will say, with my "product manager" hat on, that this truly seems like a really amazing feature, and I'd love to see it make it into Drupal 8 still, though we are now past alpha deadline so this isn't my call alone.

The entire point of Workspaces is to provide a "true" preview of changes before they happen. That currently is not possible in core, because once you've triggered a workspace, there's no way to update it with changes that happen subsequently. So while you can preview the new things you are adding within the workspace on their own, you can't preview them in context, like how they'll look on the front page (which now has so much new content it'll move yours out of the "featured" block entirely once you've deploy it, for example).

In contrib, @dixon_ mentioned that there's a solution that uses a manual "pull" content model, but editors find this a confusing model to work with (which makes sense; it's a developer's model). The current patch is much more intuitive, because it handles the "pulling" for you automatically.

But yes, unfortunately I do think the current delete logic is too dangerous, and works against user expectations. They expect a workspace to be a "private sandbox" for playing in, and if deleting content from there also deleted it from other peoples' workspaces or—even worse—from Live, that would be very unexpected/shocking behaviour, I feel. :\

I'm really hoping that since this "deny" logic is already figured out for editing, it will hopefully not be too much more work to do something similar for delete.

Also, I have to say that I really appreciate all of the thought/care that went into preparing the issue summary, preparing the video walkthroughs of how it works (and also the being up until stupid o'clock to run through it, but :P). That's SUPER helpful for orienting people who are new to the issue (like me!).

So yes, sadly back to needs work, but thanks so much for pushing on this!

Wim Leers’s picture

Issue tags: +API-First Initiative

Expose a JSON API per workspace and you suddenly have an incredibly competent content-hub solution!

😲🥳🤯

I'd sure love to hear more about this, and I think it'd really help if the issue summary explained this :)

amateescu’s picture

Status: Needs work » Needs review
FileSize
85.9 KB
37.9 KB

@webchick and @dixon_, thank you both for taking the time to review this patch and for the thoughtful comments/proposal :)

Here's an update that implements the suggested solution from #36 by enabling content deletion protection on three levels:

1. Users without the bypass node access permission will not be able to the see Delete operation at all;

2. Users with the bypass node access permission are able to see the link (because they can bypass all node access rules), but the deletion form displays a helpful message and disables the Delete button;

3. Finally, for direct $entity->delete() API calls, we throw an exception with the same message from the delete form.

Status: Needs review » Needs work

The last submitted patch, 39: 3062486-39.patch, failed testing. View results

amateescu’s picture

Fixed those fails by skipping the inherited test method, and explained why in the code comment.

dixon_’s picture

Status: Needs review » Needs work

Took this for a quick spin, and overall it looks really good. The functionality works as expected. I'll post a longer review tomorrow with a demo video.

There's one issue with the patch, it throws a type error in certain circumstances because WorkspaceManager::getActiveWorkspace() can return FALSE. Here's a quick fix I implemented while testing.

dixon_’s picture

FileSize
2.4 KB

And attaching the fix I talked about as an interdiff to the patch in comment 41.

amateescu’s picture

Status: Needs work » Needs review
FileSize
87.23 KB
2.06 KB

Good catch, @dixon_ :) Only one of the hunks from your interdiff is needed, because \Drupal\workspaces\EntityOperations::entityPredelete() and \Drupal\workspaces\EntityAccess::entityOperationAccess() already check if there's an active workspace. Added a test for this as well.

dixon_’s picture

Status: Needs review » Reviewed & tested by the community

Gone through and done plenty of manual scenario testing on this latest patch, which also fixes the type error I discovered.

IMO we're good to go! :)

catch’s picture

Did a review mainly for data integrity stuff, questions below:

  1. +++ b/core/modules/workspaces/src/Plugin/EntityReferenceSelection/WorkspaceSelection.php
    @@ -0,0 +1,72 @@
    + * Provides specific access control for the workspace entity type.
    

    This class isn't providing access control afaict.

  2. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -48,16 +58,60 @@ public function __construct(Connection $connection, EntityTypeManagerInterface $
    +    }
    +
    +    // Update all affected workspaces that were tracking the current revision.
    +    // This means they are inheriting content and should be updated.
    +    if ($tracked_revision) {
    +      $this->database->update(static::TABLE)
    +        ->fields([
    +          'target_entity_revision_id' => $entity->getRevisionId(),
    +        ])
    +        ->condition('workspace', $affected_workspaces, 'IN')
    +        ->condition('target_entity_type_id', $entity->getEntityTypeId())
    +        ->condition('target_entity_id', $entity->id())
    +        // Only update descendant workspaces if they have the same initial
    +        // revision, which means they are currently inheriting content.
    +        ->condition('target_entity_revision_id', $tracked_revision)
    +        ->execute();
    +    }
    +
    +    // Insert a new index entry for each workspace that is not tracking this
    +    // entity yet.
    +    $missing_workspaces = array_diff($affected_workspaces, $this->getEntityTrackingWorkspaceIds($entity));
    +    if ($missing_workspaces) {
    +      $insert_query = $this->database->insert(static::TABLE)
    +        ->fields([
    +          'workspace',
    +          'target_entity_revision_id',
    +          'target_entity_type_id',
    +          'target_entity_id',
    +        ]);
    +      foreach ($missing_workspaces as $workspace_id) {
    +        $insert_query->values([
    +          'workspace' => $workspace_id,
    +          'target_entity_type_id' => $entity->getEntityTypeId(),
    +          'target_entity_id' => $entity->id(),
    +          'target_entity_revision_id' => $entity->getRevisionId(),
    +        ]);
    +      }
    +      $insert_query->execute();
    +    }
    +
    

    Does this need to be in a transaction now it's more than one query?

  3. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -158,6 +212,78 @@ public function deleteAssociations($workspace_id, $entity_type_id = NULL, $entit
    +  public function isEntityDeletable(RevisionableInterface $entity, WorkspaceInterface $workspace) {
    +    if (!isset($this->deletableEntityIds[$workspace->id()][$entity->getEntityTypeId()])) {
    +      // First, get all the unpublished default revisions.
    +      $unpublished_default_revisions = $this->entityTypeManager->getStorage($entity->getEntityTypeId())
    +        ->getQuery()
    +        ->accessCheck(FALSE)
    +        ->condition($entity->getEntityType()->getKey('published'), 0)
    +        ->addMetaData('skip_workspaces_alter', TRUE)
    +        ->execute();
    +
    +      // If there are no unpublished default revisions, the entity can not be
    +      // deleted.
    +      if (!$unpublished_default_revisions) {
    +        $this->deletableEntityIds[$workspace->id()][$entity->getEntityTypeId()] = [];
    +        return FALSE;
    +      }
    +
    

    Reading this through, I was really confused why we're checking all entities when we only need the information for one. This could do with an explicit comment that we're initializing the property for all entities up front for performance reasons or similar. Although also are we really checking deletability for multiple entities during the same request much?

    Also what about moving the pre-load initialization stuff into a protected method?

  4. +++ b/core/modules/workspaces/src/WorkspaceMerger.php
    @@ -0,0 +1,209 @@
    +
    +    $transaction = $this->database->startTransaction();
    +    try {
    

    Thanks for adding a transaction here.

  5. +++ b/core/modules/workspaces/src/WorkspaceMerger.php
    @@ -0,0 +1,209 @@
    +   */
    +  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.
    +  }
    +
    

    Given we're merging entity changes from one workspace to another how does this remain true now?

  6. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +   * @internal
    +   */
    +  public function loadTree() {
    +    if (!isset($this->tree)) {
    +      $cache = $this->cache->get('workspace_tree');
    +      if ($cache) {
    +        $this->tree = $cache->data;
    +        return $this->tree;
    +      }
    +
    +      $entities = $this->loadMultiple();
    +
    +      // First, sort everything alphabetically.
    

    Published workspaces get deleted still right? Just wondering how finite every workspace on the site is going to be.

  7. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +    Cache::invalidateTags(['workspace_list']);
    

    Should this come from the entity type or similar?

plach’s picture

Status: Reviewed & tested by the community » Needs work
FileSize
75.11 KB
117.34 KB

This is an awesome addition and code looks very good to me. There are a couple of fixes still needed and a few clarifications pending (mainly the deletion logic), but I think we're very close!

  1. +++ b/core/modules/content_moderation/tests/src/Kernel/WorkspacesContentModerationStateTest.php
    @@ -164,6 +164,17 @@ public function basicModerationTestCases() {
    +    // ::testContentModerationStateRevisionDataRemoval().
    

    I didn't immediately realize this method is defined on the parent class: I'd add parent:: here to make it more obvious.

  2. +++ b/core/modules/workspaces/config/install/core.entity_form_display.workspace.workspace.deploy.yml
    @@ -11,4 +11,5 @@ bundle: workspace
    +  parent: true
    

    Don't we need a (post) update for this?

  3. +++ b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
    @@ -69,17 +69,26 @@ public static function create(ContainerInterface $container) {
    +      // If the latest revision of the entity is tracked in a workspace, it can
    +      // only be edited in that workspace or one of its descendants.
    +      if ($latest_revision_workspace = $latest_revision->workspace->entity) {
    

    I might be wrong but I didn't spot any explicit test coverage for this. I think it would be important to have both functional and integration test coverage to check both the form and the validator itself.

  4. +++ b/core/modules/workspaces/src/Entity/Workspace.php
    @@ -141,6 +153,26 @@ public function setCreatedTime($created) {
    +    // When a new workspace has been saved, we need to copy all the associations
    +    // of its parent.
    +    if (!$update && !$this->parent->isEmpty()) {
    +      \Drupal::service('workspaces.association')->initializeWorkspace($this);
    +    }
    

    Personally I'd prefer if this kind of changes happened in the storage: I realize that this may imply duplicated logic when writing an alternative storage backend, however we keep introducing dependencies from domain objects to services, which doesn't feel right. IMO these methods should be used only to manipulate the entity/field data/state.

    Just saying, since we're already doing something similar in ::postDelete(), so nothing really new added by this.
    Rant over :)

  5. +++ b/core/modules/workspaces/src/Form/WorkspaceDeleteForm.php
    @@ -66,17 +66,29 @@ public function __construct(EntityRepositoryInterface $entity_repository, Worksp
    +    foreach ($this->workspaceAssociation->getTrackedEntities($this->entity->id()) as $entity_type_id => $entity_ids) {
    +      if ($revision_ids = $this->workspaceAssociation->getAssociatedRevisions($this->entity->id(), $entity_type_id, $entity_ids)) {
    +        $label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel();
    +        $items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]);
    +      }
    ...
    +      '#access' => !empty($items),
    

    Is this change strictly related/required?

  6. +++ b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
    @@ -69,17 +69,26 @@ public static function create(ContainerInterface $container) {
    +        $descendants_and_self = array_merge([$latest_revision_workspace->id()], $workspace_tree[$latest_revision_workspace->id()]['descendants']);
    

    I'm wondering whether we could add a helper method to the storage to do this, something like ::loadSubTree(). This would of course rely on ::loadTree() internally.

  7. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -31,6 +31,16 @@ class WorkspaceAssociation implements WorkspaceAssociationInterface {
    +  protected $deletableEntityIds = [];
    

    Would it make sense to rely on MemoryCache for this?

  8. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -48,16 +58,60 @@ public function __construct(Connection $connection, EntityTypeManagerInterface $
    +    $tracked_revision = NULL;
    

    Nit: I'd call this $tracked_revision_id to improve readability.

  9. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -158,6 +212,78 @@ public function deleteAssociations($workspace_id, $entity_type_id = NULL, $entit
    +        if ((int) $revision_count_value[$revision_count_key] - 1 === $associated_revisions[$revision_count_value['nid']]) {
    

    s/'nid'/$id_key :)

  10. +++ b/core/modules/workspaces/src/WorkspaceMerger.php
    @@ -0,0 +1,209 @@
    +   * Constructs a new WorkspacePublisher.
    

    WorkspaceMerger :)

  11. +++ b/core/modules/workspaces/src/WorkspaceMerger.php
    @@ -0,0 +1,209 @@
    +        // Since we're not saving entity objects, we need to invalidate the list
    +        // cache tags manually.
    +        $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    +        $this->cacheTagsInvalidator->invalidateTags($entity_type->getListCacheTags());
    

    This seems an implementation detail to me, I'd move it into ::trackEntity. If invalidating at each entity tracking is expensive, we could add a multiple version of track entity (::trackEntities()?) invalidating only at the end of the process.

  12. +++ b/core/modules/workspaces/src/WorkspaceMerger.php
    @@ -0,0 +1,209 @@
    +  public function getDifferringRevisionIdsOnTarget() {
    

    It seems to me that this logic could be factored out to a (protected) helper method that both ::getDifferringRevisionIdsOnTarget() and ::getDifferringRevisionIdsOnTarget() could rely on.
    (Even if we only have two of them ;)

  13. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +      $entities = $this->loadMultiple();
    

    Nit: I'd rename this to $workspaces and $id to $workspace_id to improve readability: in the middle of the review I was wondering whether there were also tracked entities involved in the process besides workspaces.

  14. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +      uasort($entities, function ($a, $b) {
    

    Missing type hints.

  15. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +      // Keeps track of the parents we have to process, the last entry is used
    +      // for the next processing step.
    

    I'd explicitly mention that roots are stored with a NULL parent: I had to read this a few times before grasping the logic.

  16. +++ b/core/modules/workspaces/src/WorkspaceStorage.php
    @@ -0,0 +1,179 @@
    +          $child = current($tree_children[$parent]);
    

    $child_id :)

  17. +++ b/core/modules/workspaces/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
    @@ -111,6 +111,7 @@ protected function getExpectedNormalizedEntity() {
    +      'parent' => [],
    

    Is this a BC break for API clients? Not sure what the policies are in this case.

  18. +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php
    @@ -0,0 +1,173 @@
    +    $assert_session->linkByHrefExists($unpublished_live->toUrl('delete-form')->toString());
    ...
    +    $assert_session->linkByHrefExists($unpublished_live->toUrl('delete-form')->toString());
    ...
    +    $assert_session->linkByHrefExists($unpublished_stage->toUrl('delete-form')->toString());
    

    Based on @webchick's feedback I was assuming this would work quite differently: I'd expect an entity to be deletable only if it was created in the active workspace and if descendant workspaces have no pending changes for it, regardless of its publishing state. Otherwise I could create an unpublished default article in live, plannning to edit it later, just to see it disappear because it was deleted in the stage workspace by an editor assuming to act only in the stage scope.

  19. +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceCRUDTest.php
    @@ -294,4 +294,29 @@ public function testDeletingPublishedWorkspace() {
    +    $dev->delete();
    +    $stage->delete();
    

    Can we explicitly assert that deletion was successful?

  20. +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceMergerTest.php
    @@ -0,0 +1,209 @@
    +    // Check that Local 1 can not be merged directly into Stage, since it can
    +    // only be merged into its direct parent.
    

    This may seem minor, but it would have saved me a minute or so of head scratching: can we move this comment below, right before the assertion block, and replace it with something like "// Check that there is no content in Stage that's not also in Local 1."? I spent some time wondering why an empty result implied that the merge could not happen :D

  21. +++ b/core/modules/workspaces/workspaces.install
    @@ -158,3 +159,24 @@ function workspaces_update_8801() {
    +  // Update the storage class of the 'workspace' entity type.
    +  $entity_type = $entity_definition_update_manager->getEntityType('workspace');
    +  $entity_type->setStorageClass(WorkspaceStorage::class);
    +  $entity_definition_update_manager->updateEntityType($entity_type);
    

    We should do this only if the default storage class is set.


While manually testing the patch, I found this (minor) UX issue that seems to be pre-existing, since it happens also with the deploy action: operation links are not hidden when the operation is not allowed. Maybe there's already an issue for this, didn't check.

Edit: typos

catch’s picture

Based on @webchick's feedback I was assuming this would work quite differently: I'd expect an entity to be deletable only if it was created in the active workspace and if descendant workspaces have no pending changes for it, regardless of its publishing state. Otherwise I could create an unpublished default article in live, and plan to edit it later, just to see it disappear because it was deleted in the stage workspace by an editor assuming to act only in the stage scope.

I got a bit confused when reviewing for the wrong reasons - I was thinking about other workspaces, which of course we prevent editing in so that shouldn't happen, and thought 'oh OK then', but yes definitely creating an unpublished entity is still independently valid on live so seems like an extra check would be useful.

webchick’s picture

Based on @webchick's feedback I was assuming this would work quite differently: I'd expect an entity to be deletable only if it was created in the active workspace and if descendant workspaces have no pending changes for it, regardless of its publishing state. Otherwise I could create an unpublished default article in live, plannning to edit it later, just to see it disappear because it was deleted in the stage workspace by an editor assuming to act only in the stage scope.

That is an accurate representation for how I expected the delete logic design to work.

amateescu’s picture

Replying in separate comments to make it easier to follow :)

Re @catch in #46:

  1. Very good point, fixed and added test coverage.
  2. Yes, it does! Fixed.
  3. That multiple-entities check optimization is mostly for administration pages, which can display at least 50 entities on a page. Added a comment for that. I don't think we need to extract any parts of this into a protected method, because that code will become a lot simpler after I fix #48 / #49.
  4. :)
  5. It does remain true because we still use the EntityWorkspaceConflict validation constraint for disallowing changes to the same entity in other workspaces. With this patch, the constraint works like this:
    - given an entity that exists in Live;
    - a user edits the entity in Stage, and the new revision is inherited by Dev. At this point, the entity can no longer be edited in Live, but it can be edited in Stage or Dev;
    - if another user edits that entity in Dev, it can no longer be edited in Live nor Stage, until the Dev revision is merged into Stage, which brings us back to the second step above, where the entity can only be edited in Stage or Dev, at least until Stage is published.
  6. Published workspaces don't get deleted automatically if that's what you mean :) It's up to each site admin/editor to maintain the list workspaces...
  7. I guess it can't hurt, fixed.
dixon_’s picture

Status: Needs work » Needs review

:)

Status: Needs review » Needs work

The last submitted patch, 50: 3062486-50.patch, failed testing. View results

amateescu’s picture

Issue tags: +WI critical
FileSize
102.16 KB
36.97 KB

Re @plach in #47:

  1. Sure thing, fixed.
  2. We do! Added an update function and a test assertion for it.

    Also opened #3091481: Add explicit test coverage for EntityWorkspaceConflictConstraintValidator for the test coverage part.

  3. I discussed this at DrupalCon with @dixon_ and @pmelab and we agreed that it would be better to add a WorkspaceRepository service for the workspace tree stuff, mostly because I think the entity storage should only be concerned with CRUD operations.

    As for the specific part of the code you highlighted, we think that it actually belongs to the workspaces.association service, so I moved it there as a hook implementation.

  4. I don't think so, reverted :)
  5. Added a helper method in the new workspace.repository service: getDescendantsAndSelf().
  6. I don't think so, this static cache shouldn't need to be cleared from the outside.
  7. Fixed.
  8. Fixed.
  9. Fixed.
  10. I don't agree with this point :) ::trackEntity() is usually called when an entity is inserted or updated, so the cache is invalidated by the storage itself. This usage of ::trackEntity() in the workspace merger is a special case, so I think it's ok for it to do the cache invalidation itself.
  11. I'd like to introduce a "workspace.diff" service specially for this, and we could do your suggestion in that followup issue.
  12. Done.
  13. Fixed.
  14. Fixed.
  15. Fixed.
  16. This was discussed a lot in the issues that made taxonomy terms and menu links revisionable, and the consensus was that It's not a BC break, it's an API addition, and it's acceptable for API clients because we're not changing things for them, we're just adding extra information.
  17. This point is still TODO in the next comment, because it involves quite some changes.
  18. Sure, done.
  19. Done.
  20. Not relevant anymore because we removed the custom storage class :)

Opened #3091490: Hide the Publish and Merge operation links when they can't be performed for the UX issue.

In the next comment, I'll take care of #47.17 / #48 / #49 and the test fail. Leaving at NW because the patch will have the same failure as #50.

plach’s picture

Interdiffs look great!

@amateescu, #53:

2: Ok, I hope it won't be forgot ;)
3: Good call!
6: Ok, we can always switch to MemoryCache later, if needed.
10: Makes sense, thanks for clarifying
11: 👍
16: 👍
20: 👍


  1. +++ b/core/modules/workspaces/src/WorkspaceAssociation.php
    @@ -122,6 +128,17 @@ public function trackEntity(RevisionableInterface $entity, WorkspaceInterface $w
    +  public function workspaceInsert(WorkspaceInterface $workspace) {
    +    // When a new workspace has been saved, we need to copy all the associations
    +    // of its parent.
    +    if ($workspace->hasParent()) {
    +      \Drupal::service('workspaces.association')->initializeWorkspace($workspace);
    +    }
    +  }
    

    $this->initializeWorkspace() :)

  2. +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceMergerTest.php
    @@ -184,9 +184,9 @@ public function testWorkspaceMerger() {
    -    // Check that Local 1 can not be merged directly into Stage, since it can
    -    // only be merged into its direct parent.
    

    Nit: we could restore this comment right before the final assertions.

amateescu’s picture

Status: Needs work » Needs review
FileSize
108.3 KB
15.47 KB

Fixed #47.17 / #48 / #49, #54 as well as the failing test (and added explicit test coverage for it). I hope it's ok to fix it here...

Re #54:

1. LOL, fixed.
2. Done.

amateescu’s picture

The interdiff from #55 contains some changes to core/modules/workspaces/tests/fixtures/update/drupal-8.6.0-workspaces_installed.php, which is considered as a binary by git, so the patch doesn't apply because it wasn't created with the --binary option.

Re-rolled the patch with --binary and here's a manual interdiff for that file. In order to test the upgrade path for workspaces_post_update_update_deploy_form_display(), we have to add the deploy form display and form mode to the database fixture which installs the Workspaces module:

// Insert Workspaces' config objects.
$connection->insert('config')
->fields(array(
  'collection',
  'name',
  'data',
))
->values(array(
  'collection' => '',
  'name' => 'core.entity_form_display.workspace.workspace.deploy',
  'data' => 'a:11:{s:4:"uuid";s:36:"0208740d-b830-46d6-bc4b-9f880729a26a";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:2:{s:6:"config";a:1:{i:0;s:38:"core.entity_form_mode.workspace.deploy";}s:6:"module";a:1:{i:0;s:10:"workspaces";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"y_XXBDxxmhgsWMxWsyUrGX2giUDI6aS-cxP_5BK0WZM";}s:2:"id";s:26:"workspace.workspace.deploy";s:16:"targetEntityType";s:9:"workspace";s:6:"bundle";s:9:"workspace";s:4:"mode";s:6:"deploy";s:7:"content";a:0:{}s:6:"hidden";a:1:{s:3:"uid";b:1;}}',
))
  ->values(array(
  'collection' => '',
  'name' => 'core.entity_form_mode.workspace.deploy',
  'data' => 'a:9:{s:4:"uuid";s:36:"fd8d0149-716f-44b2-a817-fbe8b2107938";s:8:"langcode";s:2:"en";s:6:"status";b:1;s:12:"dependencies";a:1:{s:6:"module";a:1:{i:0;s:10:"workspaces";}}s:5:"_core";a:1:{s:19:"default_config_hash";s:43:"e0Wvw-yOQy3q1edTu3t5bLP5tZFdJeq9PDFhs_XEAlg";}s:2:"id";s:16:"workspace.deploy";s:5:"label";s:6:"Deploy";s:16:"targetEntityType";s:9:"workspace";s:5:"cache";b:1;}',
))
->execute();
pmelab’s picture

Reviewed all the changes since my initial patch and also ran the tests of our projects that build on top of it.

Good to go from my side!

amateescu’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @pmelab for testing! Since all the feedback has been addressed, I think the patch is ready for committers to take another look :)

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 56: 3062486-56.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review
FileSize
90.38 KB
20.89 KB

I think the "prevent entity deletion in a workspace" problem is becoming too hard to address in this issue.

I tried it until now because I thought the Workspaces module could get to stable in 8.8.0, but, since that's no longer the case, I'm going to open a separate issue for it, especially because it's a preexisting problem in HEAD and it's not made any worse by this sub-workspaces patch.

dixon_’s picture

Status: Needs review » Reviewed & tested by the community

@amateescu It makes sense to address the delete behaviour in a separate patch now that we've decided to keep Workspaces module as experimental.

I gave this latest patch a whole new round of testing (now disregarding the fact that deletes affect all workspaces). All is looking good. All comments have been addressed. Back to RTBC!

amateescu’s picture

Opened #3092247: Prevent content from being deleted when there is an active workspace and posted a test-only patch to demonstrate that the delete problem exists in HEAD :)

  • plach committed f10a397 on 9.0.x
    Issue #3062486 by amateescu, pmelab, blazey, dixon_, plach, catch,...

  • plach committed 3843ce8 on 8.9.x
    Issue #3062486 by amateescu, pmelab, blazey, dixon_, plach, catch,...

  • plach committed 71399ed on 8.8.x
    Issue #3062486 by amateescu, pmelab, blazey, dixon_, plach, catch,...
plach’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record, +Needs release note, +8.8.0 highlights

I spoke with @webchick and she is ok with committing this as-is, given that the issue is pre-existing, which #3092247: Prevent content from being deleted when there is an active workspace nicely proves. The follow-up should be a stable-blocker, though.

Given that, I went ahead and committed/pushed f10a397 to 9.0.x and friends. Congratulations!

Marking this as needing work, since the release notes snippet is missing as well as a CR announcing this new functionality to site builders and editors.

plach’s picture

Version: 8.9.x-dev » 8.8.x-dev
dixon_’s picture

Thanks everyone who's been involved here! 🎉 This is a major piece of functionality that just landed!

Thank you all!

amateescu’s picture

Issue summary: View changes
Status: Needs work » Fixed
Issue tags: -Needs change record, -Needs release note

Added a change record and a release note snippet :)

webchick’s picture

Exciting stuff, folks! Thanks for all your hard work!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

jibran’s picture

+++ b/core/modules/workspaces/src/Entity/Workspace.php
@@ -100,6 +100,17 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields['parent'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(new TranslatableMarkup('Parent'))
+      ->setDescription(new TranslatableMarkup('The parent workspace.'))
+      ->setSetting('target_type', 'workspace')
+      ->setReadOnly(TRUE)
+      ->setDisplayConfigurable('form', TRUE)
+      ->setDisplayOptions('form', [
+        'type' => 'options_select',
+        'weight' => 10,
+      ]);

+++ b/core/modules/workspaces/workspaces.install
@@ -158,3 +158,19 @@ function workspaces_update_8801() {
+  $storage_definition = BaseFieldDefinition::create('entity_reference')
+    ->setLabel(t('Parent'))
+    ->setDescription(t('The parent workspace.'))
+    ->setSetting('target_type', 'workspace')
+    ->setReadOnly(TRUE);

I was wondering should we enforce max_length here as workflow ID has ->setSetting('max_length', 128) but the question is can we enforce lenght on ER field target_id field?