Commit ebad0cd9 authored by Dries's avatar Dries

Issue #2075185 by Wim Leers: When an entity is in-place edited (i.e. saved),...

Issue #2075185 by Wim Leers: When an entity is in-place edited (i.e. saved), other instances of that entity on the same page are not updated (no propagation).
parent 90c513b8
......@@ -168,7 +168,8 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({
// Create an AJAX object for the form associated with the field.
var formSaveAjax = Drupal.edit.util.form.ajaxifySaving({
nocssjs: false
nocssjs: false,
other_view_modes: fieldModel.findOtherViewModes()
}, $submit);
// Successfully saved.
......@@ -176,8 +177,12 @@ Drupal.edit.editors.form = Drupal.edit.EditorView.extend({
cleanUpAjax();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Then, set the 'html' attribute on the field model. This will cause the
// field to be rerendered.
// Second, set the 'htmlForOtherViewModes' attribute, so that when this
// field is rerendered, the change can be propagated to other instances of
// this field, which may be displayed in different view modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will cause
// the field to be rerendered.
fieldModel.set('html', response.data);
};
......
......@@ -304,7 +304,7 @@ Drupal.edit.EntityModel = Backbone.Model.extend({
// "Save" button again.
entityModel.set('state', 'opened', { reason: 'networkerror' });
// Show a modal to inform the user of the network error.
var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', { '@entity-title' : entityModel.get('label') })
var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', { '@entity-title' : entityModel.get('label') });
Drupal.edit.util.networkErrorModal(Drupal.t('Sorry!'), message);
}
});
......
......@@ -33,6 +33,11 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
// Callback function for validating changes between states. Receives the
// previous state, new state, context, and a callback
acceptStateChange: null,
// A logical field ID, of the form
// "<entity type>/<id>/<field name>/<language>", i.e. the fieldID without
// the view mode, to be able to identify other instances of the same field
// on the page but rendered in a different view mode. e.g. "node/1/field_tags/und".
logicalFieldID: null,
// The attributes below are stateful. The ones above will never change
// during the life of a FieldModel instance.
......@@ -49,7 +54,11 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
// The full HTML representation of this field (with the element that has
// the data-edit-field-id as the outer element). Used to propagate changes
// from this field instance to other instances of the same field.
html: null
html: null,
// An object containing the full HTML representations (values) of other view
// modes (keys) of this field, for other instances of this field displayed
// in a different view mode.
htmlForOtherViewModes: null
},
/**
......@@ -61,6 +70,9 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
// Enlist field automatically in the associated entity's field collection.
this.get('entity').get('fields').add(this);
// Automatically generate the logical field ID.
this.set('logicalFieldID', this.get('fieldID').split('/').slice(0, 4).join('/'));
},
/**
......@@ -112,6 +124,46 @@ Drupal.edit.FieldModel = Backbone.Model.extend({
*/
getEntityID: function () {
return this.get('fieldID').split('/').slice(0, 2).join('/');
},
/**
* Extracts the view mode ID from this field's ID.
*
* @return String
* A view mode ID.
*/
getViewMode: function () {
return this.get('fieldID').split('/').pop();
},
/**
* Find other instances of this field with different view modes.
*
* @return Array
* An array containing view mode IDs.
*/
findOtherViewModes: function () {
var currentField = this;
var otherViewModes = [];
Drupal.edit.collections.fields
// Find all instances of fields that display the same logical field (same
// entity, same field, just a different instance and maybe a different
// view mode).
.where({ logicalFieldID: currentField.get('logicalFieldID') })
.forEach(function (field) {
// Ignore the current field.
if (field === currentField) {
return;
}
// Also ignore other fields with the same view mode.
else if (field.get('fieldID') === currentField.get('fieldID')) {
return;
}
else {
otherViewModes.push(field.getViewMode());
}
});
return otherViewModes;
}
}, {
......
......@@ -130,6 +130,8 @@ Drupal.edit.util.form = {
* An object with the following keys:
* - nocssjs: (required) boolean indicating whether no CSS and JS should be
* returned (necessary when the form is invisible to the user).
* - other_view_modes: (required) array containing view mode IDs (of other
* instances of this field on the page).
* @return Drupal.ajax
* A Drupal.ajax instance.
*/
......@@ -140,7 +142,10 @@ Drupal.edit.util.form = {
setClick: true,
event: 'click.edit',
progress: { type: null },
submit: { nocssjs : options.nocssjs },
submit: {
nocssjs : options.nocssjs,
other_view_modes : options.other_view_modes
},
// Reimplement the success handler to ensure Drupal.attachBehaviors() does
// not get called on the form.
success: function (response, status) {
......
......@@ -45,6 +45,7 @@ Drupal.edit.AppView = Backbone.View.extend({
// Track app state.
.on('change:state', this.editorStateChange, this)
// Respond to field model HTML representation change events.
.on('change:html', this.propagateUpdatedField, this)
.on('change:html', this.renderUpdatedField, this)
// Respond to addition.
.on('add', this.rerenderedFieldToCandidate, this)
......@@ -422,20 +423,33 @@ Drupal.edit.AppView = Backbone.View.extend({
*
* @param Drupal.edit.FieldModel fieldModel
* The FieldModel whose 'html' attribute changed.
* @param String html
* The updated 'html' attribute.
* @param Object options
* An object with the following keys:
* - Boolean propagation: whether this change to the 'html' attribute
* occurred because of the propagation of changes to another instance of
* this field.
*/
renderUpdatedField: function (fieldModel) {
renderUpdatedField: function (fieldModel, html, options) {
// Get data necessary to rerender property before it is unavailable.
var html = fieldModel.get('html');
var $fieldWrapper = $(fieldModel.get('el'));
var $context = $fieldWrapper.parent();
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// When propagating the changes of another instance of this field, this
// field is not being actively edited and hence no state changes are
// necessary. So: only update the state of this field when the rerendering
// of this field happens not because of propagation, but because it is being
// edited itself.
if (!options.propagation) {
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// Set the field's state to 'inactive', to enable the updating of its DOM
// value.
fieldModel.set('state', 'inactive', { reason: 'rerender' });
// Set the field's state to 'inactive', to enable the updating of its DOM
// value.
fieldModel.set('state', 'inactive', { reason: 'rerender' });
}
// Destroy the field model; this will cause all attached views to be
// destroyed too, and removal from all collections in which it exists.
......@@ -449,6 +463,56 @@ Drupal.edit.AppView = Backbone.View.extend({
Drupal.attachBehaviors($context);
},
/**
* Propagates the changes to an updated field to all instances of that field.
*
* @param Drupal.edit.FieldModel updatedField
* The FieldModel whose 'html' attribute changed.
* @param String html
* The updated 'html' attribute.
* @param Object options
* An object with the following keys:
* - Boolean propagation: whether this change to the 'html' attribute
* occurred because of the propagation of changes to another instance of
* this field.
*
* @see Drupal.edit.AppView.renderUpdatedField()
*/
propagateUpdatedField: function (updatedField, html, options) {
// Don't propagate field updates that themselves were caused by propagation.
if (options.propagation) {
return;
}
var htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes');
Drupal.edit.collections.fields
// Find all instances of fields that display the same logical field (same
// entity, same field, just a different instance and maybe a different
// view mode).
.where({ logicalFieldID: updatedField.get('logicalFieldID') })
.forEach(function (field) {
// Ignore the field that was already updated.
if (field === updatedField) {
return;
}
// If this other instance of the field has the same view mode, we can
// update it easily.
else if (field.getViewMode() === updatedField.getViewMode()) {
field.set('html', updatedField.get('html'));
}
// If this other instance of the field has a different view mode, and
// that is one of the view modes for which a re-rendered version is
// available (and that should be the case unless this field was only
// added to the page after editing of the updated field began), then use
// that view mode's re-rendered version.
else {
if (field.getViewMode() in htmlForOtherViewModes) {
field.set('html', htmlForOtherViewModes[field.getViewMode()], { propagation: true });
}
}
});
},
/**
* If the new in-place editable field is for the entity that's currently
* being edited, then transition it to the 'candidate' state.
......
......@@ -196,6 +196,7 @@ Drupal.edit.EditorView = Backbone.View.extend({
fieldID: this.fieldModel.get('fieldID'),
$el: this.$el,
nocssjs: true,
other_view_modes: fieldModel.findOtherViewModes(),
// Reset an existing entry for this entity in the TempStore (if any) when
// saving the field. Logically speaking, this should happen in a separate
// request because this is an entity-level operation, not a field-level
......@@ -232,7 +233,11 @@ Drupal.edit.EditorView = Backbone.View.extend({
removeHiddenForm();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Then, set the 'html' attribute on the field model. This will cause
// Second, set the 'htmlForOtherViewModes' attribute, so that when this
// field is rerendered, the change can be propagated to other instances of
// this field, which may be displayed in different view modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will cause
// the field to be rerendered.
fieldModel.set('html', response.data);
};
......
......@@ -15,14 +15,37 @@
*/
class FieldFormSavedCommand extends BaseCommand {
/**
* The same re-rendered edited field, but in different view modes.
*
* @var array
*/
protected $other_view_modes;
/**
* Constructs a FieldFormSavedCommand object.
*
* @param string $data
* The data to pass on to the client side.
* The re-rendered edited field to pass on to the client side.
* @param array $other_view_modes
* The same re-rendered edited field, but in different view modes, for other
* instances of the same field on the user's page. Keyed by view mode.
*/
public function __construct($data) {
public function __construct($data, $other_view_modes = array()) {
parent::__construct('editFieldFormSaved', $data);
$this->other_view_modes = $other_view_modes;
}
/**
* {@inheritdoc}
*/
public function render() {
return array(
'command' => $this->command,
'data' => $this->data,
'other_view_modes' => $this->other_view_modes,
);
}
}
......@@ -12,6 +12,7 @@
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\Component\Utility\MapArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
......@@ -233,26 +234,26 @@ public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view
// updated view of the field from the TempStore copy.
$entity = $this->tempStoreFactory->get('edit')->get($entity->uuid());
// Render the field. If the view mode ID is not an Entity Display view
// mode ID, then the field was rendered using a custom render pipeline,
// that is: not the Entity/Field API render pipeline.
// An example could be Views' render pipeline. In the example of Views,
// the view mode ID would probably contain the View's ID, display and the
// row index.
$entity_view_mode_ids = array_keys(entity_get_view_modes($entity->entityType()));
if (in_array($view_mode_id, $entity_view_mode_ids)) {
$output = field_view_field($entity, $field_name, $view_mode_id, $langcode);
}
else {
// Each part of a custom (non-Entity Display) view mode ID is separated
// by a dash; the first part must be the module name.
$mode_id_parts = explode('-', $view_mode_id, 2);
$module = reset($mode_id_parts);
$args = array($entity, $field_name, $view_mode_id, $langcode);
$output = $this->moduleHandler->invoke($module, 'edit_render_field', $args);
}
$response->addCommand(new FieldFormSavedCommand(drupal_render($output)));
// Closure to render the field given a view mode.
// @todo Drupal 8 will — but does not yet — require PHP 5.4:
// https://drupal.org/node/2152073. One of the new features in that
// version is $this support for closures. See
// http://php.net/manual/en/migration54.new-features.php.
// That will allow us to get rid of this ugly $that = $this mess.
$that = $this;
$render_field_in_view_mode = function ($view_mode_id) use ($entity, $field_name, $langcode, $that) {
return $that->renderField($entity, $field_name, $langcode, $view_mode_id);
};
// Re-render the updated field.
$output = $render_field_in_view_mode($view_mode_id);
// Re-render the updated field for other view modes (i.e. for other
// instances of the same logical field on the user's page).
$other_view_mode_ids = $request->request->get('other_view_modes') ?: array();
$other_view_modes = MapArray::copyValuesToKeys($other_view_mode_ids, $render_field_in_view_mode);
$response->addCommand(new FieldFormSavedCommand($output, $other_view_modes));
}
else {
$response->addCommand(new FieldFormCommand(drupal_render($form)));
......@@ -275,6 +276,53 @@ public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view
return $response;
}
/**
* Renders a field.
*
* If the view mode ID is not an Entity Display view mode ID, then the field
* was rendered using a custom render pipeline (not the Entity/Field API
* render pipeline).
*
* An example could be Views' render pipeline. In that case, the view mode ID
* would probably contain the View's ID, display and the row index.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being edited.
* @param string $field_name
* The name of the field that is being edited.
* @param string $langcode
* The name of the language for which the field is being edited.
* @param string $view_mode_id
* The view mode the field should be rerendered in. Either an Entity Display
* view mode ID, or a custom one. See hook_edit_render_field().
*
* @return string
* Rendered HTML.
*
* @see hook_edit_render_field()
*
* @todo Until Drupal 8 requires PHP 5.4, we cannot call $this inside a
* closure (see higher), which also means anything called from a closure
* must be public. So, until https://drupal.org/node/2152073 lands, use
* "public" instead of "protected".
*/
public function renderField(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
$entity_view_mode_ids = array_keys(entity_get_view_modes($entity->entityType()));
if (in_array($view_mode_id, $entity_view_mode_ids)) {
$output = field_view_field($entity, $field_name, $view_mode_id, $langcode);
}
else {
// Each part of a custom (non-Entity Display) view mode ID is separated
// by a dash; the first part must be the module name.
$mode_id_parts = explode('-', $view_mode_id, 2);
$module = reset($mode_id_parts);
$args = array($entity, $field_name, $view_mode_id, $langcode);
$output = $this->moduleHandler->invoke($module, 'edit_render_field', $args);
}
return drupal_render($output);
}
/**
* Saves an entity into the database, from TempStore.
*
......
......@@ -216,6 +216,7 @@ public function testUserWithPermission() {
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
$this->assertIdentical('editFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is an editFieldFormSaved command.');
$this->assertTrue(strpos($ajax_commands[0]['data'], 'Fine thanks.'), 'Form value saved and printed back.');
$this->assertIdentical($ajax_commands[0]['other_view_modes'], array(), 'Field was not rendered in any other view mode.');
// Ensure the text on the original node did not change yet.
$this->drupalGet('node/1');
......@@ -426,6 +427,9 @@ public function testCustomPipeline() {
'body[0][format]' => 'filtered_html',
'op' => t('Save'),
);
// Assume there is another field on this page, which doesn't use a custom
// render pipeline, but the default one, and it uses the "full" view mode.
$post += array('other_view_modes[]' => 'full');
// Submit field form and check response. Should render with the custom
// render pipeline.
......@@ -436,6 +440,8 @@ public function testCustomPipeline() {
$this->assertIdentical('editFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is an editFieldFormSaved command.');
$this->assertTrue(strpos($ajax_commands[0]['data'], 'Fine thanks.'), 'Form value saved and printed back.');
$this->assertTrue(strpos($ajax_commands[0]['data'], '<div class="edit-test-wrapper">') !== FALSE, 'Custom render pipeline used to render the value.');
$this->assertIdentical(array_keys($ajax_commands[0]['other_view_modes']), array('full'), 'Field was also rendered in the "full" view mode.');
$this->assertTrue(strpos($ajax_commands[0]['other_view_modes']['full'], 'Fine thanks.'), '"full" version of field contains the form value.');
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment