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) {
21 const options = $.extend(drupalSettings.quickedit,
22 // Merge strings on top of drupalSettings so that they are not mutable.
25 quickEdit: Drupal.t('Quick edit'),
31 * Tracks fields without metadata. Contains objects with the following keys:
36 let fieldsMetadataQueue = [];
39 * Tracks fields ready for use. Contains objects with the following keys:
44 let fieldsAvailableQueue = [];
47 * Tracks contextual links on entities. Contains objects with the following
53 let contextualLinksQueue = [];
56 * Tracks how many instances exist for each unique entity. Contains key-value
61 const entityInstancesTracker = {};
65 * @type {Drupal~behavior}
67 Drupal.behaviors.quickedit = {
69 // Initialize the Quick Edit app once per page load.
70 $('body').once('quickedit-init').each(initQuickEdit);
72 // Find all in-place editable fields, if any.
73 const $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
74 if ($fields.length === 0) {
78 // Process each entity element: identical entities that appear multiple
79 // times will get a numeric identifier, starting at 0.
80 $(context).find('[data-quickedit-entity-id]').once('quickedit').each((index, entityElement) => {
81 processEntity(entityElement);
84 // Process each field element: queue to be used or to fetch metadata.
85 // When a field is being rerendered after editing, it will be processed
86 // immediately. New fields will be unable to be processed immediately,
87 // but will instead be queued to have their metadata fetched, which occurs
88 // below in fetchMissingMetaData().
89 $fields.each((index, fieldElement) => {
90 processField(fieldElement);
93 // Entities and fields on the page have been detected, try to set up the
94 // contextual links for those entities that already have the necessary
95 // meta- data in the client-side cache.
96 contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
98 // Fetch metadata for any fields that are queued to retrieve it.
99 fetchMissingMetadata((fieldElementsWithFreshMetadata) => {
100 // Metadata has been fetched, reprocess fields whose metadata was
102 _.each(fieldElementsWithFreshMetadata, processField);
104 // Metadata has been fetched, try to set up more contextual links now.
105 contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
108 detach(context, settings, trigger) {
109 if (trigger === 'unload') {
110 deleteContainedModelsAndQueues($(context));
122 * A {@link Drupal.quickedit.AppView} instance.
129 * @prop {Array.<Drupal.quickedit.EntityModel>} entities
130 * @prop {Array.<Drupal.quickedit.FieldModel>} fields
133 // All in-place editable entities (Drupal.quickedit.EntityModel) on the
136 // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
141 * In-place editors will register themselves in this object.
148 * Per-field metadata that indicates whether in-place editing is allowed,
149 * which in-place editor should be used, etc.
156 * Check if a field exists in storage.
158 * @param {string} fieldID
159 * The field id to check.
162 * Whether it was found or not.
165 return storage.getItem(this._prefixFieldID(fieldID)) !== null;
169 * Add metadata to a field id.
171 * @param {string} fieldID
172 * The field ID to add data to.
173 * @param {object} metadata
176 add(fieldID, metadata) {
177 storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
181 * Get a key from a field id.
183 * @param {string} fieldID
184 * The field ID to check.
185 * @param {string} [key]
186 * The key to check. If empty, will return all metadata.
189 * The value for the key, if defined. Otherwise will return all metadata
190 * for the specified field id.
194 const metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
195 return (typeof key === 'undefined') ? metadata : metadata[key];
199 * Prefix the field id.
201 * @param {string} fieldID
202 * The field id to prefix.
205 * A prefixed field id.
207 _prefixFieldID(fieldID) {
208 return `Drupal.quickedit.metadata.${fieldID}`;
212 * Unprefix the field id.
214 * @param {string} fieldID
215 * The field id to unprefix.
218 * An unprefixed field id.
220 _unprefixFieldID(fieldID) {
221 // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
222 return fieldID.substring(26);
226 * Intersection calculation.
228 * @param {Array} fieldIDs
229 * An array of field ids to compare to prefix field id.
232 * The intersection found.
234 intersection(fieldIDs) {
235 const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
236 const intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
237 return _.map(intersection, this._unprefixFieldID);
242 // Clear the Quick Edit metadata cache whenever the current user's set of
243 // permissions changes.
244 const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
245 const permissionsHashValue = storage.getItem(permissionsHashKey);
246 const permissionsHash = drupalSettings.user.permissionsHash;
247 if (permissionsHashValue !== permissionsHash) {
248 if (typeof permissionsHash === 'string') {
249 _.chain(storage).keys().each((key) => {
250 if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
251 storage.removeItem(key);
255 storage.setItem(permissionsHashKey, permissionsHash);
259 * Detect contextual links on entities annotated by quickedit.
261 * Queue contextual links to be processed.
263 * @param {jQuery.Event} event
264 * The `drupalContextualLinkAdded` event.
265 * @param {object} data
266 * An object containing the data relevant to the event.
268 * @listens event:drupalContextualLinkAdded
270 $(document).on('drupalContextualLinkAdded', (event, data) => {
271 if (data.$region.is('[data-quickedit-entity-id]')) {
272 // If the contextual link is cached on the client side, an entity instance
273 // will not yet have been assigned. So assign one.
274 if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
275 data.$region.once('quickedit');
276 processEntity(data.$region.get(0));
278 const contextualLink = {
279 entityID: data.$region.attr('data-quickedit-entity-id'),
280 entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'),
282 region: data.$region[0],
284 // Set up contextual links for this, otherwise queue it to be set up
286 if (!initializeEntityContextualLink(contextualLink)) {
287 contextualLinksQueue.push(contextualLink);
293 * Extracts the entity ID from a field ID.
295 * @param {string} fieldID
296 * A field ID: a string of the format
297 * `<entity type>/<id>/<field name>/<language>/<view mode>`.
300 * An entity ID: a string of the format `<entity type>/<id>`.
302 function extractEntityID(fieldID) {
303 return fieldID.split('/').slice(0, 2).join('/');
307 * Initialize the Quick Edit app.
309 * @param {HTMLElement} bodyElement
310 * This document's body element.
312 function initQuickEdit(bodyElement) {
313 Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
314 Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
316 // Instantiate AppModel (application state) and AppView, which is the
317 // controller of the whole in-place editing experience.
318 Drupal.quickedit.app = new Drupal.quickedit.AppView({
320 model: new Drupal.quickedit.AppModel(),
321 entitiesCollection: Drupal.quickedit.collections.entities,
322 fieldsCollection: Drupal.quickedit.collections.fields,
327 * Assigns the entity an instance ID.
329 * @param {HTMLElement} entityElement
330 * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
333 function processEntity(entityElement) {
334 const entityID = entityElement.getAttribute('data-quickedit-entity-id');
335 if (!entityInstancesTracker.hasOwnProperty(entityID)) {
336 entityInstancesTracker[entityID] = 0;
339 entityInstancesTracker[entityID]++;
342 // Set the calculated entity instance ID for this element.
343 const entityInstanceID = entityInstancesTracker[entityID];
344 entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
348 * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
350 * @param {HTMLElement} fieldElement
351 * A Drupal Field API field's DOM element with a data-quickedit-field-id
354 function processField(fieldElement) {
355 const metadata = Drupal.quickedit.metadata;
356 const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
357 const entityID = extractEntityID(fieldID);
358 // Figure out the instance ID by looking at the ancestor
359 // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
361 const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
362 const $entityElement = $(entityElementSelector);
364 // If there are no elements returned from `entityElementSelector`
365 // throw an error. Check the browser console for this message.
366 if (!$entityElement.length) {
367 throw new Error(`Quick Edit could not associate the rendered entity field markup (with [data-quickedit-field-id="${fieldID}"]) with the corresponding rendered entity markup: no parent DOM node found with [data-quickedit-entity-id="${entityID}"]. This is typically caused by the theme's template for this entity type forgetting to print the attributes.`);
369 let entityElement = $(fieldElement).closest($entityElement);
371 // In the case of a full entity view page, the entity title is rendered
372 // outside of "the entity DOM node": it's rendered as the page title. So in
373 // this case, we find the lowest common parent element (deepest in the tree)
374 // and consider that the entity element.
375 if (entityElement.length === 0) {
376 const $lowestCommonParent = $entityElement.parents().has(fieldElement).first();
377 entityElement = $lowestCommonParent.find($entityElement);
379 const entityInstanceID = entityElement
381 .getAttribute('data-quickedit-entity-instance-id');
383 // Early-return if metadata for this field is missing.
384 if (!metadata.has(fieldID)) {
385 fieldsMetadataQueue.push({
393 // Early-return if the user is not allowed to in-place edit this field.
394 if (metadata.get(fieldID, 'access') !== true) {
398 // If an EntityModel for this field already exists (and hence also a "Quick
399 // edit" contextual link), then initialize it immediately.
400 if (Drupal.quickedit.collections.entities.findWhere({ entityID, entityInstanceID })) {
401 initializeField(fieldElement, fieldID, entityID, entityInstanceID);
403 // Otherwise: queue the field. It is now available to be set up when its
404 // corresponding entity becomes in-place editable.
406 fieldsAvailableQueue.push({ el: fieldElement, fieldID, entityID, entityInstanceID });
411 * Initialize a field; create FieldModel.
413 * @param {HTMLElement} fieldElement
414 * The field's DOM element.
415 * @param {string} fieldID
417 * @param {string} entityID
418 * The field's entity's ID.
419 * @param {string} entityInstanceID
420 * The field's entity's instance ID.
422 function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
423 const entity = Drupal.quickedit.collections.entities.findWhere({
428 $(fieldElement).addClass('quickedit-field');
430 // The FieldModel stores the state of an in-place editable entity field.
431 const field = new Drupal.quickedit.FieldModel({
434 id: `${fieldID}[${entity.get('entityInstanceID')}]`,
436 metadata: Drupal.quickedit.metadata.get(fieldID),
437 acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app),
440 // Track all fields on the page.
441 Drupal.quickedit.collections.fields.add(field);
445 * Fetches metadata for fields whose metadata is missing.
447 * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
449 * @param {function} callback
450 * A callback function that receives field elements whose metadata will just
453 function fetchMissingMetadata(callback) {
454 if (fieldsMetadataQueue.length) {
455 const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
456 const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
457 let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
458 // Ensure we only request entityIDs for which we don't have metadata yet.
459 entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
460 fieldsMetadataQueue = [];
463 url: Drupal.url('quickedit/metadata'),
466 'fields[]': fieldIDs,
467 'entities[]': entityIDs,
471 // Store the metadata.
472 _.each(results, (fieldMetadata, fieldID) => {
473 Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
476 callback(fieldElementsWithoutMetadata);
483 * Loads missing in-place editor's attachments (JavaScript and CSS files).
485 * Missing in-place editors are those whose fields are actively being used on
486 * the page but don't have.
488 * @param {function} callback
489 * Callback function to be called when the missing in-place editors (if any)
490 * have been inserted into the DOM. i.e. they may still be loading.
492 function loadMissingEditors(callback) {
493 const loadedEditors = _.keys(Drupal.quickedit.editors);
494 let missingEditors = [];
495 Drupal.quickedit.collections.fields.each((fieldModel) => {
496 const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
497 if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
498 missingEditors.push(metadata.editor);
499 // Set a stub, to prevent subsequent calls to loadMissingEditors() from
500 // loading the same in-place editor again. Loading an in-place editor
501 // requires talking to a server, to download its JavaScript, then
502 // executing its JavaScript, and only then its Drupal.quickedit.editors
503 // entry will be set.
504 Drupal.quickedit.editors[metadata.editor] = false;
507 missingEditors = _.uniq(missingEditors);
508 if (missingEditors.length === 0) {
513 // @see https://www.drupal.org/node/2029999.
514 // Create a Drupal.Ajax instance to load the form.
515 const loadEditorsAjax = Drupal.ajax({
516 url: Drupal.url('quickedit/attachments'),
517 submit: { 'editors[]': missingEditors },
519 // Implement a scoped insert AJAX command: calls the callback after all AJAX
520 // command functions have been executed (hence the deferred calling).
521 const realInsert = Drupal.AjaxCommands.prototype.insert;
522 loadEditorsAjax.commands.insert = function (ajax, response, status) {
524 realInsert(ajax, response, status);
526 // Trigger the AJAX request, which will should return AJAX commands to
527 // insert any missing attachments.
528 loadEditorsAjax.execute();
532 * Attempts to set up a "Quick edit" link and corresponding EntityModel.
534 * @param {object} contextualLink
535 * An object with the following properties:
536 * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
538 * - String entityInstanceID: a Quick Edit entity instance identifier,
539 * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
540 * instance of this entity).
541 * - DOM el: element pointing to the contextual links placeholder for this
543 * - DOM region: element pointing to the contextual region of this entity.
546 * Returns true when a contextual the given contextual link metadata can be
547 * removed from the queue (either because the contextual link has been set
548 * up or because it is certain that in-place editing is not allowed for any
549 * of its fields). Returns false otherwise.
551 function initializeEntityContextualLink(contextualLink) {
552 const metadata = Drupal.quickedit.metadata;
553 // Check if the user has permission to edit at least one of them.
554 function hasFieldWithPermission(fieldIDs) {
555 for (let i = 0; i < fieldIDs.length; i++) {
556 const fieldID = fieldIDs[i];
557 if (metadata.get(fieldID, 'access') === true) {
564 // Checks if the metadata for all given field IDs exists.
565 function allMetadataExists(fieldIDs) {
566 return fieldIDs.length === metadata.intersection(fieldIDs).length;
569 // Find all fields for this entity instance and collect their field IDs.
570 const fields = _.where(fieldsAvailableQueue, {
571 entityID: contextualLink.entityID,
572 entityInstanceID: contextualLink.entityInstanceID,
574 const fieldIDs = _.pluck(fields, 'fieldID');
576 // No fields found yet.
577 if (fieldIDs.length === 0) {
580 // The entity for the given contextual link contains at least one field that
581 // the current user may edit in-place; instantiate EntityModel,
582 // EntityDecorationView and ContextualLinkView.
583 else if (hasFieldWithPermission(fieldIDs)) {
584 const entityModel = new Drupal.quickedit.EntityModel({
585 el: contextualLink.region,
586 entityID: contextualLink.entityID,
587 entityInstanceID: contextualLink.entityInstanceID,
588 id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
589 label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
591 Drupal.quickedit.collections.entities.add(entityModel);
592 // Create an EntityDecorationView associated with the root DOM node of the
594 const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
595 el: contextualLink.region,
598 entityModel.set('entityDecorationView', entityDecorationView);
600 // Initialize all queued fields within this entity (creates FieldModels).
601 _.each(fields, (field) => {
602 initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
604 fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
606 // Initialization should only be called once. Use Underscore's once method
607 // to get a one-time use version of the function.
608 const initContextualLink = _.once(() => {
609 const $links = $(contextualLink.el).find('.contextual-links');
610 const contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
611 el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
613 appModel: Drupal.quickedit.app.model,
615 entityModel.set('contextualLinkView', contextualLinkView);
618 // Set up ContextualLinkView after loading any missing in-place editors.
619 loadMissingEditors(initContextualLink);
623 // There was not at least one field that the current user may edit in-place,
624 // even though the metadata for all fields within this entity is available.
625 else if (allMetadataExists(fieldIDs)) {
633 * Delete models and queue items that are contained within a given context.
635 * Deletes any contained EntityModels (plus their associated FieldModels and
636 * ContextualLinkView) and FieldModels, as well as the corresponding queues.
638 * After EntityModels, FieldModels must also be deleted, because it is
639 * possible in Drupal for a field DOM element to exist outside of the entity
640 * DOM element, e.g. when viewing the full node, the title of the node is not
641 * rendered within the node (the entity) but as the page title.
643 * Note: this will not delete an entity that is actively being in-place
646 * @param {jQuery} $context
647 * The context within which to delete.
649 function deleteContainedModelsAndQueues($context) {
650 $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each((index, entityElement) => {
651 // Delete entity model.
652 const entityModel = Drupal.quickedit.collections.entities.findWhere({ el: entityElement });
654 const contextualLinkView = entityModel.get('contextualLinkView');
655 contextualLinkView.undelegateEvents();
656 contextualLinkView.remove();
657 // Remove the EntityDecorationView.
658 entityModel.get('entityDecorationView').remove();
659 // Destroy the EntityModel; this will also destroy its FieldModels.
660 entityModel.destroy();
664 function hasOtherRegion(contextualLink) {
665 return contextualLink.region !== entityElement;
668 contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
671 $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each((index, fieldElement) => {
672 // Delete field models.
673 Drupal.quickedit.collections.fields.chain()
674 .filter(fieldModel => fieldModel.get('el') === fieldElement)
678 function hasOtherFieldElement(field) {
679 return field.el !== fieldElement;
682 fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
683 fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
686 }(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage));