Stop telling users that another user has modified the content in Drupal 8
Every Drupal developer knows the following error message (maybe some by heart): The content has been modified by another user, changes cannot be saved. In Drupal 8 the message is even a bit longer: The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved. While this inbuilt mechanism is very useful to preserve data integrity, the only way to get rid of the message is to reload the form and then redo the changes you want to make. This can be (or should I say 'is') very frustrating for users, especially when they have no idea why this is happening. In an environment where multiple users modify the same content, there are solutions like the Content locking module to get overcome this nagging problem. But what if your content changes a lot by backend calls ?
On a big project I'm currently working on, Musescore.com (D6 to D8), members can upload their scores to the website. On save, the file is send to Amazon where it will be processed so you can play and listen to the music in your browser. Depending on the length of a score, the processing might take a couple of minutes before it's available. In the meantime, you can edit the score because the user might want to update the title, body content, or add some new tags. While the edit form is open, the backend might be pinging back to our application notifying the score is now ready for playing and will update field values, thus saving the node. At this very moment, the changed time has been updated to the future, so when the user wants to save new values, Drupal will complain. This is just a simple example, in reality, the backend workers might be pinging a couple of times back on several occasions doing various operations and updating field values. And ironically, the user doesn't even have any permission to update one or more of these properties on the form itself. If you have ever uploaded a video to YouTube, you know that while your video is processing you can happily update your content and tags without any problem at all. That's what we want here too.
In Drupal 8, validating an entity is now decoupled from form validation. More information can be found on the Entity Validation API handbook and how they integrate with Symfony. Now, the validation plugin responsible for that message lives in EntityChangedConstraint and EntityChangedConstraintValidator. Since they are plugins, we can easily swap out the class and depending on our needs only add the violation when we really want to. What we also want is to preserve values of fields that might have been updated by a previous operation, in our case a backend call pinging back to tell us that the score is now ready for playing. Are you ready ? Here goes!
Step 1. Swap the class
All plugin managers in Core (any plugin manager should do that!) allow you to alter the definitions, so let's change the class to our own custom class.
<span style="color: #000000"><span style="color: #0000BB"><?php <br /></span><span style="color: #FF8000">/**<br> * Implements hook_validation_constraint_alter().<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">project_validation_constraint_alter</span><span style="color: #007700">(array &</span><span style="color: #0000BB">$definitions</span><span style="color: #007700">) {<br> if (isset(</span><span style="color: #0000BB">$definitions</span><span style="color: #007700">[</span><span style="color: #DD0000">'EntityChanged'</span><span style="color: #007700">])) {<br> </span><span style="color: #0000BB">$definitions</span><span style="color: #007700">[</span><span style="color: #DD0000">'EntityChanged'</span><span style="color: #007700">][</span><span style="color: #DD0000">'class'</span><span style="color: #007700">] = </span><span style="color: #DD0000">'Drupal\project\Plugin\Validation\Constraint\CustomEntityChangedConstraint'</span><span style="color: #007700">;<br> }<br>}<br></span><span style="color: #0000BB">?></span></span>
For the actual class itself, you can copy the original one, but without the annotation. The constraint plugin manager doesn't need to know about an additional new one (unless you want it to of course).
<span style="color: #000000"><span style="color: #0000BB"><?php <br /></span><span style="color: #007700">namespace </span><span style="color: #0000BB">Drupal</span><span style="color: #007700">\</span><span style="color: #0000BB">project</span><span style="color: #007700">\</span><span style="color: #0000BB">Plugin</span><span style="color: #007700">\</span><span style="color: #0000BB">Validation</span><span style="color: #007700">\</span><span style="color: #0000BB">Constraint</span><span style="color: #007700">;<br><br>use </span><span style="color: #0000BB">Symfony</span><span style="color: #007700">\</span><span style="color: #0000BB">Component</span><span style="color: #007700">\</span><span style="color: #0000BB">Validator</span><span style="color: #007700">\</span><span style="color: #0000BB">Constraint</span><span style="color: #007700">;<br><br></span><span style="color: #FF8000">/**<br> * Custom implementation of the validation constraint for the entity changed timestamp.<br> */<br></span><span style="color: #007700">class </span><span style="color: #0000BB">CustomEntityChangedConstraint </span><span style="color: #007700">extends </span><span style="color: #0000BB">Constraint </span><span style="color: #007700">{<br> public </span><span style="color: #0000BB">$message </span><span style="color: #007700">= </span><span style="color: #DD0000">'The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved. In case you still see this, then you are really unlucky this time!'</span><span style="color: #007700">;<br>}<br></span><span style="color: #0000BB">?></span></span>
Step 2: alter the node form
We want to be able to know that a validation of an entity is happening when an actual form is submitted. For this, we're adding a hidden field which stores a token based on the node id which we can then use later.
<span style="color: #000000"><span style="color: #0000BB"><?php <br /></span><span style="color: #FF8000">/**<br> * Implements hook_form_BASE_FORM_ID_alter() for \Drupal\node\NodeForm.<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">project_form_node_form_alter</span><span style="color: #007700">(&</span><span style="color: #0000BB">$form</span><span style="color: #007700">, &</span><span style="color: #0000BB">$form_state</span><span style="color: #007700">) {<br> </span><span style="color: #FF8000">/** @var \Drupal\Node\NodeInterface $node */<br> </span><span style="color: #0000BB">$node </span><span style="color: #007700">= </span><span style="color: #0000BB">$form_state</span><span style="color: #007700">-></span><span style="color: #0000BB">getFormObject</span><span style="color: #007700">()-></span><span style="color: #0000BB">getEntity</span><span style="color: #007700">();<br> if (!</span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">isNew</span><span style="color: #007700">() && </span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">bundle</span><span style="color: #007700">() == </span><span style="color: #DD0000">'your_bundle' </span><span style="color: #007700">&& </span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">getOwnerId</span><span style="color: #007700">() == \</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">currentUser</span><span style="color: #007700">()-></span><span style="color: #0000BB">id</span><span style="color: #007700">()) {<br> </span><span style="color: #0000BB">$form</span><span style="color: #007700">[</span><span style="color: #DD0000">'web_submission'</span><span style="color: #007700">] = [<br> </span><span style="color: #DD0000">'#type' </span><span style="color: #007700">=> </span><span style="color: #DD0000">'hidden'</span><span style="color: #007700">,<br> </span><span style="color: #DD0000">'#value' </span><span style="color: #007700">=> \</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">csrfToken</span><span style="color: #007700">()-></span><span style="color: #0000BB">get</span><span style="color: #007700">(</span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">id</span><span style="color: #007700">()),<br> ];<br> }<br>}<br></span><span style="color: #0000BB">?></span></span>
Step 3: Validating the entity and storing an id for later
We're getting to the tricky part. Not adding a violation is easy, but the entity that comes inside the constraint can't be changed. The reason is that ContentEntityForm rebuilts the entity when it comes in the submission phase, which means that if you would make any changes to the entity during validation, they would be lost. And it's a good idea anyway as other constraints might add violations which are necessary. To come around that, our constraint, in case the changed time is in the past, will verify if there is a valid token and call a function to store the id of the node in a static variable which can be picked up later.
<span style="color: #000000"><span style="color: #0000BB"><?php <br /></span><span style="color: #007700">namespace </span><span style="color: #0000BB">Drupal</span><span style="color: #007700">\</span><span style="color: #0000BB">project</span><span style="color: #007700">\</span><span style="color: #0000BB">Plugin</span><span style="color: #007700">\</span><span style="color: #0000BB">Validation</span><span style="color: #007700">\</span><span style="color: #0000BB">Constraint</span><span style="color: #007700">;<br><br>use </span><span style="color: #0000BB">Symfony</span><span style="color: #007700">\</span><span style="color: #0000BB">Component</span><span style="color: #007700">\</span><span style="color: #0000BB">Validator</span><span style="color: #007700">\</span><span style="color: #0000BB">Constraint</span><span style="color: #007700">;<br>use </span><span style="color: #0000BB">Symfony</span><span style="color: #007700">\</span><span style="color: #0000BB">Component</span><span style="color: #007700">\</span><span style="color: #0000BB">Validator</span><span style="color: #007700">\</span><span style="color: #0000BB">ConstraintValidator</span><span style="color: #007700">;<br><br></span><span style="color: #FF8000">/**<br> * Validates the EntityChanged constraint.<br> */<br></span><span style="color: #007700">class </span><span style="color: #0000BB">CustomEntityChangedConstraintValidator </span><span style="color: #007700">extends </span><span style="color: #0000BB">ConstraintValidator </span><span style="color: #007700">{<br><br> </span><span style="color: #FF8000">/**<br> * {@inheritdoc}<br> */<br> </span><span style="color: #007700">public function </span><span style="color: #0000BB">validate</span><span style="color: #007700">(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">, </span><span style="color: #0000BB">Constraint $constraint</span><span style="color: #007700">) {<br> if (isset(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">)) {<br> </span><span style="color: #FF8000">/** @var \Drupal\Core\Entity\EntityInterface $entity */<br> </span><span style="color: #007700">if (!</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">isNew</span><span style="color: #007700">()) {<br> </span><span style="color: #0000BB">$saved_entity </span><span style="color: #007700">= \</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">entityManager</span><span style="color: #007700">()-></span><span style="color: #0000BB">getStorage</span><span style="color: #007700">(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">getEntityTypeId</span><span style="color: #007700">())-></span><span style="color: #0000BB">loadUnchanged</span><span style="color: #007700">(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">id</span><span style="color: #007700">());<br> </span><span style="color: #FF8000">// A change to any other translation must add a violation to the current<br> // translation because there might be untranslatable shared fields.<br> </span><span style="color: #007700">if (</span><span style="color: #0000BB">$saved_entity </span><span style="color: #007700">&& </span><span style="color: #0000BB">$saved_entity</span><span style="color: #007700">-></span><span style="color: #0000BB">getChangedTimeAcrossTranslations</span><span style="color: #007700">() > </span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">getChangedTimeAcrossTranslations</span><span style="color: #007700">()) {<br> </span><span style="color: #0000BB">$add_violation </span><span style="color: #007700">= </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">;<br> if (</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">getEntityTypeId</span><span style="color: #007700">() == </span><span style="color: #DD0000">'node' </span><span style="color: #007700">&& </span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">bundle</span><span style="color: #007700">() == </span><span style="color: #DD0000">'your_bundle' </span><span style="color: #007700">&& <br> </span><span style="color: #0000BB">$this</span><span style="color: #007700">-></span><span style="color: #0000BB">isValidWebsubmission</span><span style="color: #007700">(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">id</span><span style="color: #007700">())) {<br> </span><span style="color: #0000BB">$add_violation </span><span style="color: #007700">= </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">;<br><br> </span><span style="color: #FF8000">// Store this id.<br> </span><span style="color: #0000BB">project_preserve_values_from_original_entity</span><span style="color: #007700">(</span><span style="color: #0000BB">$entity</span><span style="color: #007700">-></span><span style="color: #0000BB">id</span><span style="color: #007700">(), </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">);<br> }<br><br> </span><span style="color: #FF8000">// Add the violation if necessary.<br> </span><span style="color: #007700">if (</span><span style="color: #0000BB">$add_violation</span><span style="color: #007700">) {<br> </span><span style="color: #0000BB">$this</span><span style="color: #007700">-></span><span style="color: #0000BB">context</span><span style="color: #007700">-></span><span style="color: #0000BB">addViolation</span><span style="color: #007700">(</span><span style="color: #0000BB">$constraint</span><span style="color: #007700">-></span><span style="color: #0000BB">message</span><span style="color: #007700">);<br> }<br> }<br> }<br> }<br> }<br><br> </span><span style="color: #FF8000">/**<br> * Validate the web submission.<br> *<br> * @param $value<br> * The value.<br> *<br> * @see project_form_node_form_alter().<br> *<br> * @return bool<br> */<br> </span><span style="color: #007700">public function </span><span style="color: #0000BB">isValidWebsubmission</span><span style="color: #007700">(</span><span style="color: #0000BB">$value</span><span style="color: #007700">) {<br> if (!empty(\</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">request</span><span style="color: #007700">()-></span><span style="color: #0000BB">get</span><span style="color: #007700">(</span><span style="color: #DD0000">'web_submission'</span><span style="color: #007700">))) {<br> return \</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">csrfToken</span><span style="color: #007700">()-></span><span style="color: #0000BB">validate</span><span style="color: #007700">(\</span><span style="color: #0000BB">Drupal</span><span style="color: #007700">::</span><span style="color: #0000BB">request</span><span style="color: #007700">()-></span><span style="color: #0000BB">get</span><span style="color: #007700">(</span><span style="color: #DD0000">'web_submission'</span><span style="color: #007700">), </span><span style="color: #0000BB">$value</span><span style="color: #007700">);<br> }<br><br> return </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">;<br> }<br><br>}<br><br></span><span style="color: #FF8000">/**<br> * Function which holds a static array with ids of entities which need to<br> * preserve values from the original entity.<br> *<br> * @param $id<br> * The entity id.<br> * @param bool $set<br> * Whether to store the id or not.<br> *<br> * @return bool<br> * TRUE if id is set in the $ids array or not.<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">project_preserve_values_from_original_entity</span><span style="color: #007700">(</span><span style="color: #0000BB">$id</span><span style="color: #007700">, </span><span style="color: #0000BB">$set </span><span style="color: #007700">= </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">) {<br> static </span><span style="color: #0000BB">$ids </span><span style="color: #007700">= [];<br><br> if (</span><span style="color: #0000BB">$set </span><span style="color: #007700">&& !isset(</span><span style="color: #0000BB">$ids</span><span style="color: #007700">[</span><span style="color: #0000BB">$id</span><span style="color: #007700">])) {<br> </span><span style="color: #0000BB">$ids</span><span style="color: #007700">[</span><span style="color: #0000BB">$id</span><span style="color: #007700">] = </span><span style="color: #0000BB">TRUE</span><span style="color: #007700">;<br> }<br><br> return isset(</span><span style="color: #0000BB">$ids</span><span style="color: #007700">[</span><span style="color: #0000BB">$id</span><span style="color: #007700">]) ? </span><span style="color: #0000BB">TRUE </span><span style="color: #007700">: </span><span style="color: #0000BB">FALSE</span><span style="color: #007700">;<br>}<br></span><span style="color: #0000BB">?></span></span>
Step 4: copy over values from the original entity
So we now passed validation, even if the submitted changed timestamp is in the past of the last saved version of this node. Now we need to copy over values that might have been changed by another process that we want to preserve. In hook_node_presave() we can call project_preserve_values_from_original_entity() to ask if this entity is eligible for this operation. If so, we can just do our thing and happily copy those values, while keeping the fields that the user has changed in tact.
<span style="color: #000000"><span style="color: #0000BB"><?php <br /></span><span style="color: #FF8000">/**<br> * Implements hook_ENTITY_TYPE_presave().<br> */<br></span><span style="color: #007700">function </span><span style="color: #0000BB">project_node_presave</span><span style="color: #007700">(</span><span style="color: #0000BB">NodeInterface $node</span><span style="color: #007700">) {<br> if (!</span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">isNew</span><span style="color: #007700">() && isset(</span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">original</span><span style="color: #007700">) && </span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">bundle</span><span style="color: #007700">() == </span><span style="color: #DD0000">'your_bundle' </span><span style="color: #007700">&& </span><span style="color: #0000BB">project_preserve_values_from_original_entity</span><span style="color: #007700">(</span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">id</span><span style="color: #007700">())) {<br> </span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">set</span><span style="color: #007700">(</span><span style="color: #DD0000">'your_field'</span><span style="color: #007700">, </span><span style="color: #0000BB">$node</span><span style="color: #007700">-></span><span style="color: #0000BB">original</span><span style="color: #007700">-></span><span style="color: #0000BB">get</span><span style="color: #007700">(</span><span style="color: #DD0000">'your_field'</span><span style="color: #007700">)-></span><span style="color: #0000BB">value</span><span style="color: #007700">);<br> </span><span style="color: #FF8000">// Do many more copies here.<br> </span><span style="color: #007700">}<br>}<br></span><span style="color: #0000BB">?></span></span>
A happy user!
Not only the user is happy: backends can update whenever they want and customer support does not have to explain anymore where this annoying user facing message is coming from.