diff --git a/core/modules/edit/js/editors/formEditor.js b/core/modules/edit/js/editors/formEditor.js index adfa7483cbf5fcdeafb58a62b145b492d4e71624..2d8fb8ed27014f63d23ac2a788a0e9c2a0caca2c 100644 --- a/core/modules/edit/js/editors/formEditor.js +++ b/core/modules/edit/js/editors/formEditor.js @@ -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); }; diff --git a/core/modules/edit/js/models/EntityModel.js b/core/modules/edit/js/models/EntityModel.js index 99bd7a60c8e100ed7983180dda911f88743128cf..913abb3b831570ebda3cb6099ca3b4c8e88927d8 100644 --- a/core/modules/edit/js/models/EntityModel.js +++ b/core/modules/edit/js/models/EntityModel.js @@ -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); } }); diff --git a/core/modules/edit/js/models/FieldModel.js b/core/modules/edit/js/models/FieldModel.js index fc4a3dcb2f425e37ff2112ea9c974359984d0433..13f88cc45bae6ee6ad7fdce49c4522ca88142d3b 100644 --- a/core/modules/edit/js/models/FieldModel.js +++ b/core/modules/edit/js/models/FieldModel.js @@ -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; } }, { diff --git a/core/modules/edit/js/util.js b/core/modules/edit/js/util.js index 07290df5ff0a58eed00712a2a73791f972871b1c..6e1aaab6ad1d8699894ba4154ba0cf6e5ade3330 100644 --- a/core/modules/edit/js/util.js +++ b/core/modules/edit/js/util.js @@ -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) { diff --git a/core/modules/edit/js/views/AppView.js b/core/modules/edit/js/views/AppView.js index c4303801a94477c916541680dfd8e89d8035770b..2d85d2ebb2413253d6230465533e88b9d0dacefc 100644 --- a/core/modules/edit/js/views/AppView.js +++ b/core/modules/edit/js/views/AppView.js @@ -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. diff --git a/core/modules/edit/js/views/EditorView.js b/core/modules/edit/js/views/EditorView.js index 78abaa50467b3aa7dc3600d07fb0e30dafbb5673..ce57d6f36169a42743d7521c7f297cd2028ea9f8 100644 --- a/core/modules/edit/js/views/EditorView.js +++ b/core/modules/edit/js/views/EditorView.js @@ -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); }; diff --git a/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormSavedCommand.php b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormSavedCommand.php index 2e89c1994b5ccf74347104bbf37a8c11f89dc55c..7e2b57f6fdece6290ed04d85494288e09f46c9b5 100644 --- a/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormSavedCommand.php +++ b/core/modules/edit/lib/Drupal/edit/Ajax/FieldFormSavedCommand.php @@ -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, + ); } } diff --git a/core/modules/edit/lib/Drupal/edit/EditController.php b/core/modules/edit/lib/Drupal/edit/EditController.php index eb1865edc88f32405cef4d70f8f308ddd417b95c..412c16a7d9638b0285fcde1fc9a99ee6b3809c2f 100644 --- a/core/modules/edit/lib/Drupal/edit/EditController.php +++ b/core/modules/edit/lib/Drupal/edit/EditController.php @@ -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. * diff --git a/core/modules/edit/lib/Drupal/edit/Tests/EditLoadingTest.php b/core/modules/edit/lib/Drupal/edit/Tests/EditLoadingTest.php index cd5a38da2cd3daffb91ee1fa6f7fe51bc6d569ac..a8cc0b1a98899cf0549f18ed0414bfea57428ac6 100644 --- a/core/modules/edit/lib/Drupal/edit/Tests/EditLoadingTest.php +++ b/core/modules/edit/lib/Drupal/edit/Tests/EditLoadingTest.php @@ -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.'); } }