be5a929efbadf402e1220c1fd33ee33aa9d8a377
[yaffs-website] / web / core / modules / quickedit / js / quickedit.es6.js
1 /**
2  * @file
3  * Attaches behavior for the Quick Edit module.
4  *
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.
9  *
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.
18  */
19
20 (function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
21   const options = $.extend(
22     drupalSettings.quickedit,
23     // Merge strings on top of drupalSettings so that they are not mutable.
24     {
25       strings: {
26         quickEdit: Drupal.t('Quick edit'),
27       },
28     },
29   );
30
31   /**
32    * Tracks fields without metadata. Contains objects with the following keys:
33    *   - DOM el
34    *   - String fieldID
35    *   - String entityID
36    */
37   let fieldsMetadataQueue = [];
38
39   /**
40    * Tracks fields ready for use. Contains objects with the following keys:
41    *   - DOM el
42    *   - String fieldID
43    *   - String entityID
44    */
45   let fieldsAvailableQueue = [];
46
47   /**
48    * Tracks contextual links on entities. Contains objects with the following
49    * keys:
50    *   - String entityID
51    *   - DOM el
52    *   - DOM region
53    */
54   let contextualLinksQueue = [];
55
56   /**
57    * Tracks how many instances exist for each unique entity. Contains key-value
58    * pairs:
59    * - String entityID
60    * - Number count
61    */
62   const entityInstancesTracker = {};
63
64   /**
65    * Initialize the Quick Edit app.
66    *
67    * @param {HTMLElement} bodyElement
68    *   This document's body element.
69    */
70   function initQuickEdit(bodyElement) {
71     Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
72     Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
73
74     // Instantiate AppModel (application state) and AppView, which is the
75     // controller of the whole in-place editing experience.
76     Drupal.quickedit.app = new Drupal.quickedit.AppView({
77       el: bodyElement,
78       model: new Drupal.quickedit.AppModel(),
79       entitiesCollection: Drupal.quickedit.collections.entities,
80       fieldsCollection: Drupal.quickedit.collections.fields,
81     });
82   }
83
84   /**
85    * Assigns the entity an instance ID.
86    *
87    * @param {HTMLElement} entityElement
88    *   A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
89    *   attribute.
90    */
91   function processEntity(entityElement) {
92     const entityID = entityElement.getAttribute('data-quickedit-entity-id');
93     if (!entityInstancesTracker.hasOwnProperty(entityID)) {
94       entityInstancesTracker[entityID] = 0;
95     } else {
96       entityInstancesTracker[entityID]++;
97     }
98
99     // Set the calculated entity instance ID for this element.
100     const entityInstanceID = entityInstancesTracker[entityID];
101     entityElement.setAttribute(
102       'data-quickedit-entity-instance-id',
103       entityInstanceID,
104     );
105   }
106
107   /**
108    * Initialize a field; create FieldModel.
109    *
110    * @param {HTMLElement} fieldElement
111    *   The field's DOM element.
112    * @param {string} fieldID
113    *   The field's ID.
114    * @param {string} entityID
115    *   The field's entity's ID.
116    * @param {string} entityInstanceID
117    *   The field's entity's instance ID.
118    */
119   function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
120     const entity = Drupal.quickedit.collections.entities.findWhere({
121       entityID,
122       entityInstanceID,
123     });
124
125     $(fieldElement).addClass('quickedit-field');
126
127     // The FieldModel stores the state of an in-place editable entity field.
128     const field = new Drupal.quickedit.FieldModel({
129       el: fieldElement,
130       fieldID,
131       id: `${fieldID}[${entity.get('entityInstanceID')}]`,
132       entity,
133       metadata: Drupal.quickedit.metadata.get(fieldID),
134       acceptStateChange: _.bind(
135         Drupal.quickedit.app.acceptEditorStateChange,
136         Drupal.quickedit.app,
137       ),
138     });
139
140     // Track all fields on the page.
141     Drupal.quickedit.collections.fields.add(field);
142   }
143
144   /**
145    * Loads missing in-place editor's attachments (JavaScript and CSS files).
146    *
147    * Missing in-place editors are those whose fields are actively being used on
148    * the page but don't have.
149    *
150    * @param {function} callback
151    *   Callback function to be called when the missing in-place editors (if any)
152    *   have been inserted into the DOM. i.e. they may still be loading.
153    */
154   function loadMissingEditors(callback) {
155     const loadedEditors = _.keys(Drupal.quickedit.editors);
156     let missingEditors = [];
157     Drupal.quickedit.collections.fields.each(fieldModel => {
158       const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
159       if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
160         missingEditors.push(metadata.editor);
161         // Set a stub, to prevent subsequent calls to loadMissingEditors() from
162         // loading the same in-place editor again. Loading an in-place editor
163         // requires talking to a server, to download its JavaScript, then
164         // executing its JavaScript, and only then its Drupal.quickedit.editors
165         // entry will be set.
166         Drupal.quickedit.editors[metadata.editor] = false;
167       }
168     });
169     missingEditors = _.uniq(missingEditors);
170     if (missingEditors.length === 0) {
171       callback();
172       return;
173     }
174
175     // @see https://www.drupal.org/node/2029999.
176     // Create a Drupal.Ajax instance to load the form.
177     const loadEditorsAjax = Drupal.ajax({
178       url: Drupal.url('quickedit/attachments'),
179       submit: { 'editors[]': missingEditors },
180     });
181     // Implement a scoped insert AJAX command: calls the callback after all AJAX
182     // command functions have been executed (hence the deferred calling).
183     const realInsert = Drupal.AjaxCommands.prototype.insert;
184     loadEditorsAjax.commands.insert = function(ajax, response, status) {
185       _.defer(callback);
186       realInsert(ajax, response, status);
187     };
188     // Trigger the AJAX request, which will should return AJAX commands to
189     // insert any missing attachments.
190     loadEditorsAjax.execute();
191   }
192
193   /**
194    * Attempts to set up a "Quick edit" link and corresponding EntityModel.
195    *
196    * @param {object} contextualLink
197    *   An object with the following properties:
198    *     - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
199    *       "block_content/5".
200    *     - String entityInstanceID: a Quick Edit entity instance identifier,
201    *       e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
202    *       instance of this entity).
203    *     - DOM el: element pointing to the contextual links placeholder for this
204    *       entity.
205    *     - DOM region: element pointing to the contextual region of this entity.
206    *
207    * @return {bool}
208    *   Returns true when a contextual the given contextual link metadata can be
209    *   removed from the queue (either because the contextual link has been set
210    *   up or because it is certain that in-place editing is not allowed for any
211    *   of its fields). Returns false otherwise.
212    */
213   function initializeEntityContextualLink(contextualLink) {
214     const metadata = Drupal.quickedit.metadata;
215     // Check if the user has permission to edit at least one of them.
216     function hasFieldWithPermission(fieldIDs) {
217       for (let i = 0; i < fieldIDs.length; i++) {
218         const fieldID = fieldIDs[i];
219         if (metadata.get(fieldID, 'access') === true) {
220           return true;
221         }
222       }
223       return false;
224     }
225
226     // Checks if the metadata for all given field IDs exists.
227     function allMetadataExists(fieldIDs) {
228       return fieldIDs.length === metadata.intersection(fieldIDs).length;
229     }
230
231     // Find all fields for this entity instance and collect their field IDs.
232     const fields = _.where(fieldsAvailableQueue, {
233       entityID: contextualLink.entityID,
234       entityInstanceID: contextualLink.entityInstanceID,
235     });
236     const fieldIDs = _.pluck(fields, 'fieldID');
237
238     // No fields found yet.
239     if (fieldIDs.length === 0) {
240       return false;
241     }
242     // The entity for the given contextual link contains at least one field that
243     // the current user may edit in-place; instantiate EntityModel,
244     // EntityDecorationView and ContextualLinkView.
245     if (hasFieldWithPermission(fieldIDs)) {
246       const entityModel = new Drupal.quickedit.EntityModel({
247         el: contextualLink.region,
248         entityID: contextualLink.entityID,
249         entityInstanceID: contextualLink.entityInstanceID,
250         id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
251         label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
252       });
253       Drupal.quickedit.collections.entities.add(entityModel);
254       // Create an EntityDecorationView associated with the root DOM node of the
255       // entity.
256       const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
257         el: contextualLink.region,
258         model: entityModel,
259       });
260       entityModel.set('entityDecorationView', entityDecorationView);
261
262       // Initialize all queued fields within this entity (creates FieldModels).
263       _.each(fields, field => {
264         initializeField(
265           field.el,
266           field.fieldID,
267           contextualLink.entityID,
268           contextualLink.entityInstanceID,
269         );
270       });
271       fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
272
273       // Initialization should only be called once. Use Underscore's once method
274       // to get a one-time use version of the function.
275       const initContextualLink = _.once(() => {
276         const $links = $(contextualLink.el).find('.contextual-links');
277         const contextualLinkView = new Drupal.quickedit.ContextualLinkView(
278           $.extend(
279             {
280               el: $(
281                 '<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>',
282               ).prependTo($links),
283               model: entityModel,
284               appModel: Drupal.quickedit.app.model,
285             },
286             options,
287           ),
288         );
289         entityModel.set('contextualLinkView', contextualLinkView);
290       });
291
292       // Set up ContextualLinkView after loading any missing in-place editors.
293       loadMissingEditors(initContextualLink);
294
295       return true;
296     }
297     // There was not at least one field that the current user may edit in-place,
298     // even though the metadata for all fields within this entity is available.
299     if (allMetadataExists(fieldIDs)) {
300       return true;
301     }
302
303     return false;
304   }
305
306   /**
307    * Extracts the entity ID from a field ID.
308    *
309    * @param {string} fieldID
310    *   A field ID: a string of the format
311    *   `<entity type>/<id>/<field name>/<language>/<view mode>`.
312    *
313    * @return {string}
314    *   An entity ID: a string of the format `<entity type>/<id>`.
315    */
316   function extractEntityID(fieldID) {
317     return fieldID
318       .split('/')
319       .slice(0, 2)
320       .join('/');
321   }
322
323   /**
324    * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
325    *
326    * @param {HTMLElement} fieldElement
327    *   A Drupal Field API field's DOM element with a data-quickedit-field-id
328    *   attribute.
329    */
330   function processField(fieldElement) {
331     const metadata = Drupal.quickedit.metadata;
332     const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
333     const entityID = extractEntityID(fieldID);
334     // Figure out the instance ID by looking at the ancestor
335     // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
336     // attribute.
337     const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
338     const $entityElement = $(entityElementSelector);
339
340     // If there are no elements returned from `entityElementSelector`
341     // throw an error. Check the browser console for this message.
342     if (!$entityElement.length) {
343       throw new Error(
344         `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.`,
345       );
346     }
347     let entityElement = $(fieldElement).closest($entityElement);
348
349     // In the case of a full entity view page, the entity title is rendered
350     // outside of "the entity DOM node": it's rendered as the page title. So in
351     // this case, we find the lowest common parent element (deepest in the tree)
352     // and consider that the entity element.
353     if (entityElement.length === 0) {
354       const $lowestCommonParent = $entityElement
355         .parents()
356         .has(fieldElement)
357         .first();
358       entityElement = $lowestCommonParent.find($entityElement);
359     }
360     const entityInstanceID = entityElement
361       .get(0)
362       .getAttribute('data-quickedit-entity-instance-id');
363
364     // Early-return if metadata for this field is missing.
365     if (!metadata.has(fieldID)) {
366       fieldsMetadataQueue.push({
367         el: fieldElement,
368         fieldID,
369         entityID,
370         entityInstanceID,
371       });
372       return;
373     }
374     // Early-return if the user is not allowed to in-place edit this field.
375     if (metadata.get(fieldID, 'access') !== true) {
376       return;
377     }
378
379     // If an EntityModel for this field already exists (and hence also a "Quick
380     // edit" contextual link), then initialize it immediately.
381     if (
382       Drupal.quickedit.collections.entities.findWhere({
383         entityID,
384         entityInstanceID,
385       })
386     ) {
387       initializeField(fieldElement, fieldID, entityID, entityInstanceID);
388     }
389     // Otherwise: queue the field. It is now available to be set up when its
390     // corresponding entity becomes in-place editable.
391     else {
392       fieldsAvailableQueue.push({
393         el: fieldElement,
394         fieldID,
395         entityID,
396         entityInstanceID,
397       });
398     }
399   }
400
401   /**
402    * Delete models and queue items that are contained within a given context.
403    *
404    * Deletes any contained EntityModels (plus their associated FieldModels and
405    * ContextualLinkView) and FieldModels, as well as the corresponding queues.
406    *
407    * After EntityModels, FieldModels must also be deleted, because it is
408    * possible in Drupal for a field DOM element to exist outside of the entity
409    * DOM element, e.g. when viewing the full node, the title of the node is not
410    * rendered within the node (the entity) but as the page title.
411    *
412    * Note: this will not delete an entity that is actively being in-place
413    * edited.
414    *
415    * @param {jQuery} $context
416    *   The context within which to delete.
417    */
418   function deleteContainedModelsAndQueues($context) {
419     $context
420       .find('[data-quickedit-entity-id]')
421       .addBack('[data-quickedit-entity-id]')
422       .each((index, entityElement) => {
423         // Delete entity model.
424         const entityModel = Drupal.quickedit.collections.entities.findWhere({
425           el: entityElement,
426         });
427         if (entityModel) {
428           const contextualLinkView = entityModel.get('contextualLinkView');
429           contextualLinkView.undelegateEvents();
430           contextualLinkView.remove();
431           // Remove the EntityDecorationView.
432           entityModel.get('entityDecorationView').remove();
433           // Destroy the EntityModel; this will also destroy its FieldModels.
434           entityModel.destroy();
435         }
436
437         // Filter queue.
438         function hasOtherRegion(contextualLink) {
439           return contextualLink.region !== entityElement;
440         }
441
442         contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
443       });
444
445     $context
446       .find('[data-quickedit-field-id]')
447       .addBack('[data-quickedit-field-id]')
448       .each((index, fieldElement) => {
449         // Delete field models.
450         Drupal.quickedit.collections.fields
451           .chain()
452           .filter(fieldModel => fieldModel.get('el') === fieldElement)
453           .invoke('destroy');
454
455         // Filter queues.
456         function hasOtherFieldElement(field) {
457           return field.el !== fieldElement;
458         }
459
460         fieldsMetadataQueue = _.filter(
461           fieldsMetadataQueue,
462           hasOtherFieldElement,
463         );
464         fieldsAvailableQueue = _.filter(
465           fieldsAvailableQueue,
466           hasOtherFieldElement,
467         );
468       });
469   }
470
471   /**
472    * Fetches metadata for fields whose metadata is missing.
473    *
474    * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
475    *
476    * @param {function} callback
477    *   A callback function that receives field elements whose metadata will just
478    *   have been fetched.
479    */
480   function fetchMissingMetadata(callback) {
481     if (fieldsMetadataQueue.length) {
482       const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
483       const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
484       let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
485       // Ensure we only request entityIDs for which we don't have metadata yet.
486       entityIDs = _.difference(
487         entityIDs,
488         Drupal.quickedit.metadata.intersection(entityIDs),
489       );
490       fieldsMetadataQueue = [];
491
492       $.ajax({
493         url: Drupal.url('quickedit/metadata'),
494         type: 'POST',
495         data: {
496           'fields[]': fieldIDs,
497           'entities[]': entityIDs,
498         },
499         dataType: 'json',
500         success(results) {
501           // Store the metadata.
502           _.each(results, (fieldMetadata, fieldID) => {
503             Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
504           });
505
506           callback(fieldElementsWithoutMetadata);
507         },
508       });
509     }
510   }
511
512   /**
513    *
514    * @type {Drupal~behavior}
515    */
516   Drupal.behaviors.quickedit = {
517     attach(context) {
518       // Initialize the Quick Edit app once per page load.
519       $('body')
520         .once('quickedit-init')
521         .each(initQuickEdit);
522
523       // Find all in-place editable fields, if any.
524       const $fields = $(context)
525         .find('[data-quickedit-field-id]')
526         .once('quickedit');
527       if ($fields.length === 0) {
528         return;
529       }
530
531       // Process each entity element: identical entities that appear multiple
532       // times will get a numeric identifier, starting at 0.
533       $(context)
534         .find('[data-quickedit-entity-id]')
535         .once('quickedit')
536         .each((index, entityElement) => {
537           processEntity(entityElement);
538         });
539
540       // Process each field element: queue to be used or to fetch metadata.
541       // When a field is being rerendered after editing, it will be processed
542       // immediately. New fields will be unable to be processed immediately,
543       // but will instead be queued to have their metadata fetched, which occurs
544       // below in fetchMissingMetaData().
545       $fields.each((index, fieldElement) => {
546         processField(fieldElement);
547       });
548
549       // Entities and fields on the page have been detected, try to set up the
550       // contextual links for those entities that already have the necessary
551       // meta- data in the client-side cache.
552       contextualLinksQueue = _.filter(
553         contextualLinksQueue,
554         contextualLink => !initializeEntityContextualLink(contextualLink),
555       );
556
557       // Fetch metadata for any fields that are queued to retrieve it.
558       fetchMissingMetadata(fieldElementsWithFreshMetadata => {
559         // Metadata has been fetched, reprocess fields whose metadata was
560         // missing.
561         _.each(fieldElementsWithFreshMetadata, processField);
562
563         // Metadata has been fetched, try to set up more contextual links now.
564         contextualLinksQueue = _.filter(
565           contextualLinksQueue,
566           contextualLink => !initializeEntityContextualLink(contextualLink),
567         );
568       });
569     },
570     detach(context, settings, trigger) {
571       if (trigger === 'unload') {
572         deleteContainedModelsAndQueues($(context));
573       }
574     },
575   };
576
577   /**
578    *
579    * @namespace
580    */
581   Drupal.quickedit = {
582     /**
583      * A {@link Drupal.quickedit.AppView} instance.
584      */
585     app: null,
586
587     /**
588      * @type {object}
589      *
590      * @prop {Array.<Drupal.quickedit.EntityModel>} entities
591      * @prop {Array.<Drupal.quickedit.FieldModel>} fields
592      */
593     collections: {
594       // All in-place editable entities (Drupal.quickedit.EntityModel) on the
595       // page.
596       entities: null,
597       // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
598       fields: null,
599     },
600
601     /**
602      * In-place editors will register themselves in this object.
603      *
604      * @namespace
605      */
606     editors: {},
607
608     /**
609      * Per-field metadata that indicates whether in-place editing is allowed,
610      * which in-place editor should be used, etc.
611      *
612      * @namespace
613      */
614     metadata: {
615       /**
616        * Check if a field exists in storage.
617        *
618        * @param {string} fieldID
619        *   The field id to check.
620        *
621        * @return {bool}
622        *   Whether it was found or not.
623        */
624       has(fieldID) {
625         return storage.getItem(this._prefixFieldID(fieldID)) !== null;
626       },
627
628       /**
629        * Add metadata to a field id.
630        *
631        * @param {string} fieldID
632        *   The field ID to add data to.
633        * @param {object} metadata
634        *   Metadata to add.
635        */
636       add(fieldID, metadata) {
637         storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
638       },
639
640       /**
641        * Get a key from a field id.
642        *
643        * @param {string} fieldID
644        *   The field ID to check.
645        * @param {string} [key]
646        *   The key to check. If empty, will return all metadata.
647        *
648        * @return {object|*}
649        *   The value for the key, if defined. Otherwise will return all metadata
650        *   for the specified field id.
651        *
652        */
653       get(fieldID, key) {
654         const metadata = JSON.parse(
655           storage.getItem(this._prefixFieldID(fieldID)),
656         );
657         return typeof key === 'undefined' ? metadata : metadata[key];
658       },
659
660       /**
661        * Prefix the field id.
662        *
663        * @param {string} fieldID
664        *   The field id to prefix.
665        *
666        * @return {string}
667        *   A prefixed field id.
668        */
669       _prefixFieldID(fieldID) {
670         return `Drupal.quickedit.metadata.${fieldID}`;
671       },
672
673       /**
674        * Unprefix the field id.
675        *
676        * @param {string} fieldID
677        *   The field id to unprefix.
678        *
679        * @return {string}
680        *   An unprefixed field id.
681        */
682       _unprefixFieldID(fieldID) {
683         // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
684         return fieldID.substring(26);
685       },
686
687       /**
688        * Intersection calculation.
689        *
690        * @param {Array} fieldIDs
691        *   An array of field ids to compare to prefix field id.
692        *
693        * @return {Array}
694        *   The intersection found.
695        */
696       intersection(fieldIDs) {
697         const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
698         const intersection = _.intersection(
699           prefixedFieldIDs,
700           _.keys(sessionStorage),
701         );
702         return _.map(intersection, this._unprefixFieldID);
703       },
704     },
705   };
706
707   // Clear the Quick Edit metadata cache whenever the current user's set of
708   // permissions changes.
709   const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID(
710     'permissionsHash',
711   );
712   const permissionsHashValue = storage.getItem(permissionsHashKey);
713   const permissionsHash = drupalSettings.user.permissionsHash;
714   if (permissionsHashValue !== permissionsHash) {
715     if (typeof permissionsHash === 'string') {
716       _.chain(storage)
717         .keys()
718         .each(key => {
719           if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
720             storage.removeItem(key);
721           }
722         });
723     }
724     storage.setItem(permissionsHashKey, permissionsHash);
725   }
726
727   /**
728    * Detect contextual links on entities annotated by quickedit.
729    *
730    * Queue contextual links to be processed.
731    *
732    * @param {jQuery.Event} event
733    *   The `drupalContextualLinkAdded` event.
734    * @param {object} data
735    *   An object containing the data relevant to the event.
736    *
737    * @listens event:drupalContextualLinkAdded
738    */
739   $(document).on('drupalContextualLinkAdded', (event, data) => {
740     if (data.$region.is('[data-quickedit-entity-id]')) {
741       // If the contextual link is cached on the client side, an entity instance
742       // will not yet have been assigned. So assign one.
743       if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
744         data.$region.once('quickedit');
745         processEntity(data.$region.get(0));
746       }
747       const contextualLink = {
748         entityID: data.$region.attr('data-quickedit-entity-id'),
749         entityInstanceID: data.$region.attr(
750           'data-quickedit-entity-instance-id',
751         ),
752         el: data.$el[0],
753         region: data.$region[0],
754       };
755       // Set up contextual links for this, otherwise queue it to be set up
756       // later.
757       if (!initializeEntityContextualLink(contextualLink)) {
758         contextualLinksQueue.push(contextualLink);
759       }
760     }
761   });
762 })(
763   jQuery,
764   _,
765   Backbone,
766   Drupal,
767   drupalSettings,
768   window.JSON,
769   window.sessionStorage,
770 );