3 * Attaches behavior for the Quick Edit module.
5 * Everything happens asynchronously, to allow for:
6 * - dynamically rendered contextual links
7 * - asynchronously retrieved (and cached) per-field in-place editing metadata
8 * - asynchronous setup of in-place editable field and "Quick edit" link.
10 * To achieve this, there are several queues:
11 * - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
12 * - fieldsAvailableQueue: queue of fields whose metadata is known, and for
13 * which it has been confirmed that the user has permission to edit them.
14 * However, FieldModels will only be created for them once there's a
15 * contextual link for their entity: when it's possible to initiate editing.
16 * - contextualLinksQueue: queue of contextual links on entities for which it
17 * is not yet known whether the user has permission to edit at >=1 of them.
20 (function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
24 var options = $.extend(drupalSettings.quickedit,
25 // Merge strings on top of drupalSettings so that they are not mutable.
28 quickEdit: Drupal.t('Quick edit')
34 * Tracks fields without metadata. Contains objects with the following keys:
39 var fieldsMetadataQueue = [];
42 * Tracks fields ready for use. Contains objects with the following keys:
47 var fieldsAvailableQueue = [];
50 * Tracks contextual links on entities. Contains objects with the following
56 var contextualLinksQueue = [];
59 * Tracks how many instances exist for each unique entity. Contains key-value
64 var entityInstancesTracker = {};
68 * @type {Drupal~behavior}
70 Drupal.behaviors.quickedit = {
71 attach: function (context) {
72 // Initialize the Quick Edit app once per page load.
73 $('body').once('quickedit-init').each(initQuickEdit);
75 // Find all in-place editable fields, if any.
76 var $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
77 if ($fields.length === 0) {
81 // Process each entity element: identical entities that appear multiple
82 // times will get a numeric identifier, starting at 0.
83 $(context).find('[data-quickedit-entity-id]').once('quickedit').each(function (index, entityElement) {
84 processEntity(entityElement);
87 // Process each field element: queue to be used or to fetch metadata.
88 // When a field is being rerendered after editing, it will be processed
89 // immediately. New fields will be unable to be processed immediately,
90 // but will instead be queued to have their metadata fetched, which occurs
91 // below in fetchMissingMetaData().
92 $fields.each(function (index, fieldElement) {
93 processField(fieldElement);
96 // Entities and fields on the page have been detected, try to set up the
97 // contextual links for those entities that already have the necessary
98 // meta- data in the client-side cache.
99 contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
100 return !initializeEntityContextualLink(contextualLink);
103 // Fetch metadata for any fields that are queued to retrieve it.
104 fetchMissingMetadata(function (fieldElementsWithFreshMetadata) {
105 // Metadata has been fetched, reprocess fields whose metadata was
107 _.each(fieldElementsWithFreshMetadata, processField);
109 // Metadata has been fetched, try to set up more contextual links now.
110 contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
111 return !initializeEntityContextualLink(contextualLink);
115 detach: function (context, settings, trigger) {
116 if (trigger === 'unload') {
117 deleteContainedModelsAndQueues($(context));
129 * A {@link Drupal.quickedit.AppView} instance.
136 * @prop {Array.<Drupal.quickedit.EntityModel>} entities
137 * @prop {Array.<Drupal.quickedit.FieldModel>} fields
140 // All in-place editable entities (Drupal.quickedit.EntityModel) on the
143 // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
148 * In-place editors will register themselves in this object.
155 * Per-field metadata that indicates whether in-place editing is allowed,
156 * which in-place editor should be used, etc.
163 * Check if a field exists in storage.
165 * @param {string} fieldID
166 * The field id to check.
169 * Whether it was found or not.
171 has: function (fieldID) {
172 return storage.getItem(this._prefixFieldID(fieldID)) !== null;
176 * Add metadata to a field id.
178 * @param {string} fieldID
179 * The field ID to add data to.
180 * @param {object} metadata
183 add: function (fieldID, metadata) {
184 storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
188 * Get a key from a field id.
190 * @param {string} fieldID
191 * The field ID to check.
192 * @param {string} [key]
193 * The key to check. If empty, will return all metadata.
196 * The value for the key, if defined. Otherwise will return all metadata
197 * for the specified field id.
200 get: function (fieldID, key) {
201 var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
202 return (typeof key === 'undefined') ? metadata : metadata[key];
206 * Prefix the field id.
208 * @param {string} fieldID
209 * The field id to prefix.
212 * A prefixed field id.
214 _prefixFieldID: function (fieldID) {
215 return 'Drupal.quickedit.metadata.' + fieldID;
219 * Unprefix the field id.
221 * @param {string} fieldID
222 * The field id to unprefix.
225 * An unprefixed field id.
227 _unprefixFieldID: function (fieldID) {
228 // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
229 return fieldID.substring(26);
233 * Intersection calculation.
235 * @param {Array} fieldIDs
236 * An array of field ids to compare to prefix field id.
239 * The intersection found.
241 intersection: function (fieldIDs) {
242 var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
243 var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
244 return _.map(intersection, this._unprefixFieldID);
249 // Clear the Quick Edit metadata cache whenever the current user's set of
250 // permissions changes.
251 var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
252 var permissionsHashValue = storage.getItem(permissionsHashKey);
253 var permissionsHash = drupalSettings.user.permissionsHash;
254 if (permissionsHashValue !== permissionsHash) {
255 if (typeof permissionsHash === 'string') {
256 _.chain(storage).keys().each(function (key) {
257 if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
258 storage.removeItem(key);
262 storage.setItem(permissionsHashKey, permissionsHash);
266 * Detect contextual links on entities annotated by quickedit.
268 * Queue contextual links to be processed.
270 * @param {jQuery.Event} event
271 * The `drupalContextualLinkAdded` event.
272 * @param {object} data
273 * An object containing the data relevant to the event.
275 * @listens event:drupalContextualLinkAdded
277 $(document).on('drupalContextualLinkAdded', function (event, data) {
278 if (data.$region.is('[data-quickedit-entity-id]')) {
279 // If the contextual link is cached on the client side, an entity instance
280 // will not yet have been assigned. So assign one.
281 if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
282 data.$region.once('quickedit');
283 processEntity(data.$region.get(0));
285 var contextualLink = {
286 entityID: data.$region.attr('data-quickedit-entity-id'),
287 entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'),
289 region: data.$region[0]
291 // Set up contextual links for this, otherwise queue it to be set up
293 if (!initializeEntityContextualLink(contextualLink)) {
294 contextualLinksQueue.push(contextualLink);
300 * Extracts the entity ID from a field ID.
302 * @param {string} fieldID
303 * A field ID: a string of the format
304 * `<entity type>/<id>/<field name>/<language>/<view mode>`.
307 * An entity ID: a string of the format `<entity type>/<id>`.
309 function extractEntityID(fieldID) {
310 return fieldID.split('/').slice(0, 2).join('/');
314 * Initialize the Quick Edit app.
316 * @param {HTMLElement} bodyElement
317 * This document's body element.
319 function initQuickEdit(bodyElement) {
320 Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
321 Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
323 // Instantiate AppModel (application state) and AppView, which is the
324 // controller of the whole in-place editing experience.
325 Drupal.quickedit.app = new Drupal.quickedit.AppView({
327 model: new Drupal.quickedit.AppModel(),
328 entitiesCollection: Drupal.quickedit.collections.entities,
329 fieldsCollection: Drupal.quickedit.collections.fields
334 * Assigns the entity an instance ID.
336 * @param {HTMLElement} entityElement
337 * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
340 function processEntity(entityElement) {
341 var entityID = entityElement.getAttribute('data-quickedit-entity-id');
342 if (!entityInstancesTracker.hasOwnProperty(entityID)) {
343 entityInstancesTracker[entityID] = 0;
346 entityInstancesTracker[entityID]++;
349 // Set the calculated entity instance ID for this element.
350 var entityInstanceID = entityInstancesTracker[entityID];
351 entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
355 * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
357 * @param {HTMLElement} fieldElement
358 * A Drupal Field API field's DOM element with a data-quickedit-field-id
361 function processField(fieldElement) {
362 var metadata = Drupal.quickedit.metadata;
363 var fieldID = fieldElement.getAttribute('data-quickedit-field-id');
364 var entityID = extractEntityID(fieldID);
365 // Figure out the instance ID by looking at the ancestor
366 // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
368 var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]';
369 var entityElement = $(fieldElement).closest(entityElementSelector);
370 // In the case of a full entity view page, the entity title is rendered
371 // outside of "the entity DOM node": it's rendered as the page title. So in
372 // this case, we find the lowest common parent element (deepest in the tree)
373 // and consider that the entity element.
374 if (entityElement.length === 0) {
375 var $lowestCommonParent = $(entityElementSelector).parents().has(fieldElement).first();
376 entityElement = $lowestCommonParent.find(entityElementSelector);
378 var entityInstanceID = entityElement
380 .getAttribute('data-quickedit-entity-instance-id');
382 // Early-return if metadata for this field is missing.
383 if (!metadata.has(fieldID)) {
384 fieldsMetadataQueue.push({
388 entityInstanceID: entityInstanceID
392 // Early-return if the user is not allowed to in-place edit this field.
393 if (metadata.get(fieldID, 'access') !== true) {
397 // If an EntityModel for this field already exists (and hence also a "Quick
398 // edit" contextual link), then initialize it immediately.
399 if (Drupal.quickedit.collections.entities.findWhere({entityID: entityID, entityInstanceID: entityInstanceID})) {
400 initializeField(fieldElement, fieldID, entityID, entityInstanceID);
402 // Otherwise: queue the field. It is now available to be set up when its
403 // corresponding entity becomes in-place editable.
405 fieldsAvailableQueue.push({el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID});
410 * Initialize a field; create FieldModel.
412 * @param {HTMLElement} fieldElement
413 * The field's DOM element.
414 * @param {string} fieldID
416 * @param {string} entityID
417 * The field's entity's ID.
418 * @param {string} entityInstanceID
419 * The field's entity's instance ID.
421 function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
422 var entity = Drupal.quickedit.collections.entities.findWhere({
424 entityInstanceID: entityInstanceID
427 $(fieldElement).addClass('quickedit-field');
429 // The FieldModel stores the state of an in-place editable entity field.
430 var field = new Drupal.quickedit.FieldModel({
433 id: fieldID + '[' + entity.get('entityInstanceID') + ']',
435 metadata: Drupal.quickedit.metadata.get(fieldID),
436 acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app)
439 // Track all fields on the page.
440 Drupal.quickedit.collections.fields.add(field);
444 * Fetches metadata for fields whose metadata is missing.
446 * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
448 * @param {function} callback
449 * A callback function that receives field elements whose metadata will just
452 function fetchMissingMetadata(callback) {
453 if (fieldsMetadataQueue.length) {
454 var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
455 var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
456 var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
457 // Ensure we only request entityIDs for which we don't have metadata yet.
458 entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
459 fieldsMetadataQueue = [];
462 url: Drupal.url('quickedit/metadata'),
465 'fields[]': fieldIDs,
466 'entities[]': entityIDs
469 success: function (results) {
470 // Store the metadata.
471 _.each(results, function (fieldMetadata, fieldID) {
472 Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
475 callback(fieldElementsWithoutMetadata);
482 * Loads missing in-place editor's attachments (JavaScript and CSS files).
484 * Missing in-place editors are those whose fields are actively being used on
485 * the page but don't have.
487 * @param {function} callback
488 * Callback function to be called when the missing in-place editors (if any)
489 * have been inserted into the DOM. i.e. they may still be loading.
491 function loadMissingEditors(callback) {
492 var loadedEditors = _.keys(Drupal.quickedit.editors);
493 var missingEditors = [];
494 Drupal.quickedit.collections.fields.each(function (fieldModel) {
495 var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
496 if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
497 missingEditors.push(metadata.editor);
498 // Set a stub, to prevent subsequent calls to loadMissingEditors() from
499 // loading the same in-place editor again. Loading an in-place editor
500 // requires talking to a server, to download its JavaScript, then
501 // executing its JavaScript, and only then its Drupal.quickedit.editors
502 // entry will be set.
503 Drupal.quickedit.editors[metadata.editor] = false;
506 missingEditors = _.uniq(missingEditors);
507 if (missingEditors.length === 0) {
512 // @see https://www.drupal.org/node/2029999.
513 // Create a Drupal.Ajax instance to load the form.
514 var loadEditorsAjax = Drupal.ajax({
515 url: Drupal.url('quickedit/attachments'),
516 submit: {'editors[]': missingEditors}
518 // Implement a scoped insert AJAX command: calls the callback after all AJAX
519 // command functions have been executed (hence the deferred calling).
520 var realInsert = Drupal.AjaxCommands.prototype.insert;
521 loadEditorsAjax.commands.insert = function (ajax, response, status) {
523 realInsert(ajax, response, status);
525 // Trigger the AJAX request, which will should return AJAX commands to
526 // insert any missing attachments.
527 loadEditorsAjax.execute();
531 * Attempts to set up a "Quick edit" link and corresponding EntityModel.
533 * @param {object} contextualLink
534 * An object with the following properties:
535 * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
537 * - String entityInstanceID: a Quick Edit entity instance identifier,
538 * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
539 * instance of this entity).
540 * - DOM el: element pointing to the contextual links placeholder for this
542 * - DOM region: element pointing to the contextual region of this entity.
545 * Returns true when a contextual the given contextual link metadata can be
546 * removed from the queue (either because the contextual link has been set
547 * up or because it is certain that in-place editing is not allowed for any
548 * of its fields). Returns false otherwise.
550 function initializeEntityContextualLink(contextualLink) {
551 var metadata = Drupal.quickedit.metadata;
552 // Check if the user has permission to edit at least one of them.
553 function hasFieldWithPermission(fieldIDs) {
554 for (var i = 0; i < fieldIDs.length; i++) {
555 var fieldID = fieldIDs[i];
556 if (metadata.get(fieldID, 'access') === true) {
563 // Checks if the metadata for all given field IDs exists.
564 function allMetadataExists(fieldIDs) {
565 return fieldIDs.length === metadata.intersection(fieldIDs).length;
568 // Find all fields for this entity instance and collect their field IDs.
569 var fields = _.where(fieldsAvailableQueue, {
570 entityID: contextualLink.entityID,
571 entityInstanceID: contextualLink.entityInstanceID
573 var fieldIDs = _.pluck(fields, 'fieldID');
575 // No fields found yet.
576 if (fieldIDs.length === 0) {
579 // The entity for the given contextual link contains at least one field that
580 // the current user may edit in-place; instantiate EntityModel,
581 // EntityDecorationView and ContextualLinkView.
582 else if (hasFieldWithPermission(fieldIDs)) {
583 var entityModel = new Drupal.quickedit.EntityModel({
584 el: contextualLink.region,
585 entityID: contextualLink.entityID,
586 entityInstanceID: contextualLink.entityInstanceID,
587 id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']',
588 label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label')
590 Drupal.quickedit.collections.entities.add(entityModel);
591 // Create an EntityDecorationView associated with the root DOM node of the
593 var entityDecorationView = new Drupal.quickedit.EntityDecorationView({
594 el: contextualLink.region,
597 entityModel.set('entityDecorationView', entityDecorationView);
599 // Initialize all queued fields within this entity (creates FieldModels).
600 _.each(fields, function (field) {
601 initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
603 fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
605 // Initialization should only be called once. Use Underscore's once method
606 // to get a one-time use version of the function.
607 var initContextualLink = _.once(function () {
608 var $links = $(contextualLink.el).find('.contextual-links');
609 var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
610 el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
612 appModel: Drupal.quickedit.app.model
614 entityModel.set('contextualLinkView', contextualLinkView);
617 // Set up ContextualLinkView after loading any missing in-place editors.
618 loadMissingEditors(initContextualLink);
622 // There was not at least one field that the current user may edit in-place,
623 // even though the metadata for all fields within this entity is available.
624 else if (allMetadataExists(fieldIDs)) {
632 * Delete models and queue items that are contained within a given context.
634 * Deletes any contained EntityModels (plus their associated FieldModels and
635 * ContextualLinkView) and FieldModels, as well as the corresponding queues.
637 * After EntityModels, FieldModels must also be deleted, because it is
638 * possible in Drupal for a field DOM element to exist outside of the entity
639 * DOM element, e.g. when viewing the full node, the title of the node is not
640 * rendered within the node (the entity) but as the page title.
642 * Note: this will not delete an entity that is actively being in-place
645 * @param {jQuery} $context
646 * The context within which to delete.
648 function deleteContainedModelsAndQueues($context) {
649 $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) {
650 // Delete entity model.
651 var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement});
653 var contextualLinkView = entityModel.get('contextualLinkView');
654 contextualLinkView.undelegateEvents();
655 contextualLinkView.remove();
656 // Remove the EntityDecorationView.
657 entityModel.get('entityDecorationView').remove();
658 // Destroy the EntityModel; this will also destroy its FieldModels.
659 entityModel.destroy();
663 function hasOtherRegion(contextualLink) {
664 return contextualLink.region !== entityElement;
667 contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
670 $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) {
671 // Delete field models.
672 Drupal.quickedit.collections.fields.chain()
673 .filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; })
677 function hasOtherFieldElement(field) {
678 return field.el !== fieldElement;
681 fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
682 fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
686 })(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);