Updated Drupal to 8.6. This goes with the following updates because it's possible...
[yaffs-website] / web / core / modules / quickedit / js / quickedit.es6.js
index ba742033fcac818a83753b7ec07bed0a066c7ca4..be5a929efbadf402e1220c1fd33ee33aa9d8a377 100644 (file)
@@ -17,8 +17,9 @@
  *     is not yet known whether the user has permission to edit at >=1 of them.
  */
 
-(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
-  const options = $.extend(drupalSettings.quickedit,
+(function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
+  const options = $.extend(
+    drupalSettings.quickedit,
     // Merge strings on top of drupalSettings so that they are not mutable.
     {
       strings: {
    */
   const entityInstancesTracker = {};
 
-  /**
-   *
-   * @type {Drupal~behavior}
-   */
-  Drupal.behaviors.quickedit = {
-    attach(context) {
-      // Initialize the Quick Edit app once per page load.
-      $('body').once('quickedit-init').each(initQuickEdit);
-
-      // Find all in-place editable fields, if any.
-      const $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
-      if ($fields.length === 0) {
-        return;
-      }
-
-      // Process each entity element: identical entities that appear multiple
-      // times will get a numeric identifier, starting at 0.
-      $(context).find('[data-quickedit-entity-id]').once('quickedit').each((index, entityElement) => {
-        processEntity(entityElement);
-      });
-
-      // Process each field element: queue to be used or to fetch metadata.
-      // When a field is being rerendered after editing, it will be processed
-      // immediately. New fields will be unable to be processed immediately,
-      // but will instead be queued to have their metadata fetched, which occurs
-      // below in fetchMissingMetaData().
-      $fields.each((index, fieldElement) => {
-        processField(fieldElement);
-      });
-
-      // Entities and fields on the page have been detected, try to set up the
-      // contextual links for those entities that already have the necessary
-      // meta- data in the client-side cache.
-      contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
-
-      // Fetch metadata for any fields that are queued to retrieve it.
-      fetchMissingMetadata((fieldElementsWithFreshMetadata) => {
-        // Metadata has been fetched, reprocess fields whose metadata was
-        // missing.
-        _.each(fieldElementsWithFreshMetadata, processField);
-
-        // Metadata has been fetched, try to set up more contextual links now.
-        contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
-      });
-    },
-    detach(context, settings, trigger) {
-      if (trigger === 'unload') {
-        deleteContainedModelsAndQueues($(context));
-      }
-    },
-  };
-
-  /**
-   *
-   * @namespace
-   */
-  Drupal.quickedit = {
-
-    /**
-     * A {@link Drupal.quickedit.AppView} instance.
-     */
-    app: null,
-
-    /**
-     * @type {object}
-     *
-     * @prop {Array.<Drupal.quickedit.EntityModel>} entities
-     * @prop {Array.<Drupal.quickedit.FieldModel>} fields
-     */
-    collections: {
-      // All in-place editable entities (Drupal.quickedit.EntityModel) on the
-      // page.
-      entities: null,
-      // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
-      fields: null,
-    },
-
-    /**
-     * In-place editors will register themselves in this object.
-     *
-     * @namespace
-     */
-    editors: {},
-
-    /**
-     * Per-field metadata that indicates whether in-place editing is allowed,
-     * which in-place editor should be used, etc.
-     *
-     * @namespace
-     */
-    metadata: {
-
-      /**
-       * Check if a field exists in storage.
-       *
-       * @param {string} fieldID
-       *   The field id to check.
-       *
-       * @return {bool}
-       *   Whether it was found or not.
-       */
-      has(fieldID) {
-        return storage.getItem(this._prefixFieldID(fieldID)) !== null;
-      },
-
-      /**
-       * Add metadata to a field id.
-       *
-       * @param {string} fieldID
-       *   The field ID to add data to.
-       * @param {object} metadata
-       *   Metadata to add.
-       */
-      add(fieldID, metadata) {
-        storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
-      },
-
-      /**
-       * Get a key from a field id.
-       *
-       * @param {string} fieldID
-       *   The field ID to check.
-       * @param {string} [key]
-       *   The key to check. If empty, will return all metadata.
-       *
-       * @return {object|*}
-       *   The value for the key, if defined. Otherwise will return all metadata
-       *   for the specified field id.
-       *
-       */
-      get(fieldID, key) {
-        const metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
-        return (typeof key === 'undefined') ? metadata : metadata[key];
-      },
-
-      /**
-       * Prefix the field id.
-       *
-       * @param {string} fieldID
-       *   The field id to prefix.
-       *
-       * @return {string}
-       *   A prefixed field id.
-       */
-      _prefixFieldID(fieldID) {
-        return `Drupal.quickedit.metadata.${fieldID}`;
-      },
-
-      /**
-       * Unprefix the field id.
-       *
-       * @param {string} fieldID
-       *   The field id to unprefix.
-       *
-       * @return {string}
-       *   An unprefixed field id.
-       */
-      _unprefixFieldID(fieldID) {
-        // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
-        return fieldID.substring(26);
-      },
-
-      /**
-       * Intersection calculation.
-       *
-       * @param {Array} fieldIDs
-       *   An array of field ids to compare to prefix field id.
-       *
-       * @return {Array}
-       *   The intersection found.
-       */
-      intersection(fieldIDs) {
-        const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
-        const intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
-        return _.map(intersection, this._unprefixFieldID);
-      },
-    },
-  };
-
-  // Clear the Quick Edit metadata cache whenever the current user's set of
-  // permissions changes.
-  const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
-  const permissionsHashValue = storage.getItem(permissionsHashKey);
-  const permissionsHash = drupalSettings.user.permissionsHash;
-  if (permissionsHashValue !== permissionsHash) {
-    if (typeof permissionsHash === 'string') {
-      _.chain(storage).keys().each((key) => {
-        if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
-          storage.removeItem(key);
-        }
-      });
-    }
-    storage.setItem(permissionsHashKey, permissionsHash);
-  }
-
-  /**
-   * Detect contextual links on entities annotated by quickedit.
-   *
-   * Queue contextual links to be processed.
-   *
-   * @param {jQuery.Event} event
-   *   The `drupalContextualLinkAdded` event.
-   * @param {object} data
-   *   An object containing the data relevant to the event.
-   *
-   * @listens event:drupalContextualLinkAdded
-   */
-  $(document).on('drupalContextualLinkAdded', (event, data) => {
-    if (data.$region.is('[data-quickedit-entity-id]')) {
-      // If the contextual link is cached on the client side, an entity instance
-      // will not yet have been assigned. So assign one.
-      if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
-        data.$region.once('quickedit');
-        processEntity(data.$region.get(0));
-      }
-      const contextualLink = {
-        entityID: data.$region.attr('data-quickedit-entity-id'),
-        entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'),
-        el: data.$el[0],
-        region: data.$region[0],
-      };
-      // Set up contextual links for this, otherwise queue it to be set up
-      // later.
-      if (!initializeEntityContextualLink(contextualLink)) {
-        contextualLinksQueue.push(contextualLink);
-      }
-    }
-  });
-
-  /**
-   * Extracts the entity ID from a field ID.
-   *
-   * @param {string} fieldID
-   *   A field ID: a string of the format
-   *   `<entity type>/<id>/<field name>/<language>/<view mode>`.
-   *
-   * @return {string}
-   *   An entity ID: a string of the format `<entity type>/<id>`.
-   */
-  function extractEntityID(fieldID) {
-    return fieldID.split('/').slice(0, 2).join('/');
-  }
-
   /**
    * Initialize the Quick Edit app.
    *
     const entityID = entityElement.getAttribute('data-quickedit-entity-id');
     if (!entityInstancesTracker.hasOwnProperty(entityID)) {
       entityInstancesTracker[entityID] = 0;
-    }
-    else {
+    } else {
       entityInstancesTracker[entityID]++;
     }
 
     // Set the calculated entity instance ID for this element.
     const entityInstanceID = entityInstancesTracker[entityID];
-    entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
+    entityElement.setAttribute(
+      'data-quickedit-entity-instance-id',
+      entityInstanceID,
+    );
   }
 
   /**
-   * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
+   * Initialize a field; create FieldModel.
    *
    * @param {HTMLElement} fieldElement
-   *   A Drupal Field API field's DOM element with a data-quickedit-field-id
-   *   attribute.
+   *   The field's DOM element.
+   * @param {string} fieldID
+   *   The field's ID.
+   * @param {string} entityID
+   *   The field's entity's ID.
+   * @param {string} entityInstanceID
+   *   The field's entity's instance ID.
    */
-  function processField(fieldElement) {
-    const metadata = Drupal.quickedit.metadata;
-    const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
-    const entityID = extractEntityID(fieldID);
-    // Figure out the instance ID by looking at the ancestor
-    // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
-    // attribute.
-    const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
-    const $entityElement = $(entityElementSelector);
-
-    // If there are no elements returned from `entityElementSelector`
-    // throw an error. Check the browser console for this message.
-    if (!$entityElement.length) {
-      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.`);
-    }
-    let entityElement = $(fieldElement).closest($entityElement);
-
-    // In the case of a full entity view page, the entity title is rendered
-    // outside of "the entity DOM node": it's rendered as the page title. So in
-    // this case, we find the lowest common parent element (deepest in the tree)
-    // and consider that the entity element.
-    if (entityElement.length === 0) {
-      const $lowestCommonParent = $entityElement.parents().has(fieldElement).first();
-      entityElement = $lowestCommonParent.find($entityElement);
-    }
-    const entityInstanceID = entityElement
-      .get(0)
-      .getAttribute('data-quickedit-entity-instance-id');
-
-    // Early-return if metadata for this field is missing.
-    if (!metadata.has(fieldID)) {
-      fieldsMetadataQueue.push({
-        el: fieldElement,
-        fieldID,
-        entityID,
-        entityInstanceID,
-      });
-      return;
-    }
-    // Early-return if the user is not allowed to in-place edit this field.
-    if (metadata.get(fieldID, 'access') !== true) {
-      return;
-    }
-
-    // If an EntityModel for this field already exists (and hence also a "Quick
-    // edit" contextual link), then initialize it immediately.
-    if (Drupal.quickedit.collections.entities.findWhere({ entityID, entityInstanceID })) {
-      initializeField(fieldElement, fieldID, entityID, entityInstanceID);
-    }
-    // Otherwise: queue the field. It is now available to be set up when its
-    // corresponding entity becomes in-place editable.
-    else {
-      fieldsAvailableQueue.push({ el: fieldElement, fieldID, entityID, entityInstanceID });
-    }
-  }
-
-  /**
-   * Initialize a field; create FieldModel.
-   *
-   * @param {HTMLElement} fieldElement
-   *   The field's DOM element.
-   * @param {string} fieldID
-   *   The field's ID.
-   * @param {string} entityID
-   *   The field's entity's ID.
-   * @param {string} entityInstanceID
-   *   The field's entity's instance ID.
-   */
-  function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
-    const entity = Drupal.quickedit.collections.entities.findWhere({
-      entityID,
-      entityInstanceID,
-    });
+  function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
+    const entity = Drupal.quickedit.collections.entities.findWhere({
+      entityID,
+      entityInstanceID,
+    });
 
     $(fieldElement).addClass('quickedit-field');
 
       id: `${fieldID}[${entity.get('entityInstanceID')}]`,
       entity,
       metadata: Drupal.quickedit.metadata.get(fieldID),
-      acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app),
+      acceptStateChange: _.bind(
+        Drupal.quickedit.app.acceptEditorStateChange,
+        Drupal.quickedit.app,
+      ),
     });
 
     // Track all fields on the page.
     Drupal.quickedit.collections.fields.add(field);
   }
 
-  /**
-   * Fetches metadata for fields whose metadata is missing.
-   *
-   * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
-   *
-   * @param {function} callback
-   *   A callback function that receives field elements whose metadata will just
-   *   have been fetched.
-   */
-  function fetchMissingMetadata(callback) {
-    if (fieldsMetadataQueue.length) {
-      const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
-      const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
-      let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
-      // Ensure we only request entityIDs for which we don't have metadata yet.
-      entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
-      fieldsMetadataQueue = [];
-
-      $.ajax({
-        url: Drupal.url('quickedit/metadata'),
-        type: 'POST',
-        data: {
-          'fields[]': fieldIDs,
-          'entities[]': entityIDs,
-        },
-        dataType: 'json',
-        success(results) {
-          // Store the metadata.
-          _.each(results, (fieldMetadata, fieldID) => {
-            Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
-          });
-
-          callback(fieldElementsWithoutMetadata);
-        },
-      });
-    }
-  }
-
   /**
    * Loads missing in-place editor's attachments (JavaScript and CSS files).
    *
   function loadMissingEditors(callback) {
     const loadedEditors = _.keys(Drupal.quickedit.editors);
     let missingEditors = [];
-    Drupal.quickedit.collections.fields.each((fieldModel) => {
+    Drupal.quickedit.collections.fields.each(fieldModel => {
       const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
       if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
         missingEditors.push(metadata.editor);
     // Implement a scoped insert AJAX command: calls the callback after all AJAX
     // command functions have been executed (hence the deferred calling).
     const realInsert = Drupal.AjaxCommands.prototype.insert;
-    loadEditorsAjax.commands.insert = function (ajax, response, status) {
+    loadEditorsAjax.commands.insert = function(ajax, response, status) {
       _.defer(callback);
       realInsert(ajax, response, status);
     };
     // The entity for the given contextual link contains at least one field that
     // the current user may edit in-place; instantiate EntityModel,
     // EntityDecorationView and ContextualLinkView.
-    else if (hasFieldWithPermission(fieldIDs)) {
+    if (hasFieldWithPermission(fieldIDs)) {
       const entityModel = new Drupal.quickedit.EntityModel({
         el: contextualLink.region,
         entityID: contextualLink.entityID,
       entityModel.set('entityDecorationView', entityDecorationView);
 
       // Initialize all queued fields within this entity (creates FieldModels).
-      _.each(fields, (field) => {
-        initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
+      _.each(fields, field => {
+        initializeField(
+          field.el,
+          field.fieldID,
+          contextualLink.entityID,
+          contextualLink.entityInstanceID,
+        );
       });
       fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
 
       // to get a one-time use version of the function.
       const initContextualLink = _.once(() => {
         const $links = $(contextualLink.el).find('.contextual-links');
-        const contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
-          el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
-          model: entityModel,
-          appModel: Drupal.quickedit.app.model,
-        }, options));
+        const contextualLinkView = new Drupal.quickedit.ContextualLinkView(
+          $.extend(
+            {
+              el: $(
+                '<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>',
+              ).prependTo($links),
+              model: entityModel,
+              appModel: Drupal.quickedit.app.model,
+            },
+            options,
+          ),
+        );
         entityModel.set('contextualLinkView', contextualLinkView);
       });
 
     }
     // There was not at least one field that the current user may edit in-place,
     // even though the metadata for all fields within this entity is available.
-    else if (allMetadataExists(fieldIDs)) {
+    if (allMetadataExists(fieldIDs)) {
       return true;
     }
 
     return false;
   }
 
+  /**
+   * Extracts the entity ID from a field ID.
+   *
+   * @param {string} fieldID
+   *   A field ID: a string of the format
+   *   `<entity type>/<id>/<field name>/<language>/<view mode>`.
+   *
+   * @return {string}
+   *   An entity ID: a string of the format `<entity type>/<id>`.
+   */
+  function extractEntityID(fieldID) {
+    return fieldID
+      .split('/')
+      .slice(0, 2)
+      .join('/');
+  }
+
+  /**
+   * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
+   *
+   * @param {HTMLElement} fieldElement
+   *   A Drupal Field API field's DOM element with a data-quickedit-field-id
+   *   attribute.
+   */
+  function processField(fieldElement) {
+    const metadata = Drupal.quickedit.metadata;
+    const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
+    const entityID = extractEntityID(fieldID);
+    // Figure out the instance ID by looking at the ancestor
+    // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
+    // attribute.
+    const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
+    const $entityElement = $(entityElementSelector);
+
+    // If there are no elements returned from `entityElementSelector`
+    // throw an error. Check the browser console for this message.
+    if (!$entityElement.length) {
+      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.`,
+      );
+    }
+    let entityElement = $(fieldElement).closest($entityElement);
+
+    // In the case of a full entity view page, the entity title is rendered
+    // outside of "the entity DOM node": it's rendered as the page title. So in
+    // this case, we find the lowest common parent element (deepest in the tree)
+    // and consider that the entity element.
+    if (entityElement.length === 0) {
+      const $lowestCommonParent = $entityElement
+        .parents()
+        .has(fieldElement)
+        .first();
+      entityElement = $lowestCommonParent.find($entityElement);
+    }
+    const entityInstanceID = entityElement
+      .get(0)
+      .getAttribute('data-quickedit-entity-instance-id');
+
+    // Early-return if metadata for this field is missing.
+    if (!metadata.has(fieldID)) {
+      fieldsMetadataQueue.push({
+        el: fieldElement,
+        fieldID,
+        entityID,
+        entityInstanceID,
+      });
+      return;
+    }
+    // Early-return if the user is not allowed to in-place edit this field.
+    if (metadata.get(fieldID, 'access') !== true) {
+      return;
+    }
+
+    // If an EntityModel for this field already exists (and hence also a "Quick
+    // edit" contextual link), then initialize it immediately.
+    if (
+      Drupal.quickedit.collections.entities.findWhere({
+        entityID,
+        entityInstanceID,
+      })
+    ) {
+      initializeField(fieldElement, fieldID, entityID, entityInstanceID);
+    }
+    // Otherwise: queue the field. It is now available to be set up when its
+    // corresponding entity becomes in-place editable.
+    else {
+      fieldsAvailableQueue.push({
+        el: fieldElement,
+        fieldID,
+        entityID,
+        entityInstanceID,
+      });
+    }
+  }
+
   /**
    * Delete models and queue items that are contained within a given context.
    *
    *   The context within which to delete.
    */
   function deleteContainedModelsAndQueues($context) {
-    $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each((index, entityElement) => {
-      // Delete entity model.
-      const entityModel = Drupal.quickedit.collections.entities.findWhere({ el: entityElement });
-      if (entityModel) {
-        const contextualLinkView = entityModel.get('contextualLinkView');
-        contextualLinkView.undelegateEvents();
-        contextualLinkView.remove();
-        // Remove the EntityDecorationView.
-        entityModel.get('entityDecorationView').remove();
-        // Destroy the EntityModel; this will also destroy its FieldModels.
-        entityModel.destroy();
-      }
+    $context
+      .find('[data-quickedit-entity-id]')
+      .addBack('[data-quickedit-entity-id]')
+      .each((index, entityElement) => {
+        // Delete entity model.
+        const entityModel = Drupal.quickedit.collections.entities.findWhere({
+          el: entityElement,
+        });
+        if (entityModel) {
+          const contextualLinkView = entityModel.get('contextualLinkView');
+          contextualLinkView.undelegateEvents();
+          contextualLinkView.remove();
+          // Remove the EntityDecorationView.
+          entityModel.get('entityDecorationView').remove();
+          // Destroy the EntityModel; this will also destroy its FieldModels.
+          entityModel.destroy();
+        }
+
+        // Filter queue.
+        function hasOtherRegion(contextualLink) {
+          return contextualLink.region !== entityElement;
+        }
 
-      // Filter queue.
-      function hasOtherRegion(contextualLink) {
-        return contextualLink.region !== entityElement;
+        contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
+      });
+
+    $context
+      .find('[data-quickedit-field-id]')
+      .addBack('[data-quickedit-field-id]')
+      .each((index, fieldElement) => {
+        // Delete field models.
+        Drupal.quickedit.collections.fields
+          .chain()
+          .filter(fieldModel => fieldModel.get('el') === fieldElement)
+          .invoke('destroy');
+
+        // Filter queues.
+        function hasOtherFieldElement(field) {
+          return field.el !== fieldElement;
+        }
+
+        fieldsMetadataQueue = _.filter(
+          fieldsMetadataQueue,
+          hasOtherFieldElement,
+        );
+        fieldsAvailableQueue = _.filter(
+          fieldsAvailableQueue,
+          hasOtherFieldElement,
+        );
+      });
+  }
+
+  /**
+   * Fetches metadata for fields whose metadata is missing.
+   *
+   * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
+   *
+   * @param {function} callback
+   *   A callback function that receives field elements whose metadata will just
+   *   have been fetched.
+   */
+  function fetchMissingMetadata(callback) {
+    if (fieldsMetadataQueue.length) {
+      const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
+      const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
+      let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
+      // Ensure we only request entityIDs for which we don't have metadata yet.
+      entityIDs = _.difference(
+        entityIDs,
+        Drupal.quickedit.metadata.intersection(entityIDs),
+      );
+      fieldsMetadataQueue = [];
+
+      $.ajax({
+        url: Drupal.url('quickedit/metadata'),
+        type: 'POST',
+        data: {
+          'fields[]': fieldIDs,
+          'entities[]': entityIDs,
+        },
+        dataType: 'json',
+        success(results) {
+          // Store the metadata.
+          _.each(results, (fieldMetadata, fieldID) => {
+            Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
+          });
+
+          callback(fieldElementsWithoutMetadata);
+        },
+      });
+    }
+  }
+
+  /**
+   *
+   * @type {Drupal~behavior}
+   */
+  Drupal.behaviors.quickedit = {
+    attach(context) {
+      // Initialize the Quick Edit app once per page load.
+      $('body')
+        .once('quickedit-init')
+        .each(initQuickEdit);
+
+      // Find all in-place editable fields, if any.
+      const $fields = $(context)
+        .find('[data-quickedit-field-id]')
+        .once('quickedit');
+      if ($fields.length === 0) {
+        return;
       }
 
-      contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
-    });
+      // Process each entity element: identical entities that appear multiple
+      // times will get a numeric identifier, starting at 0.
+      $(context)
+        .find('[data-quickedit-entity-id]')
+        .once('quickedit')
+        .each((index, entityElement) => {
+          processEntity(entityElement);
+        });
+
+      // Process each field element: queue to be used or to fetch metadata.
+      // When a field is being rerendered after editing, it will be processed
+      // immediately. New fields will be unable to be processed immediately,
+      // but will instead be queued to have their metadata fetched, which occurs
+      // below in fetchMissingMetaData().
+      $fields.each((index, fieldElement) => {
+        processField(fieldElement);
+      });
+
+      // Entities and fields on the page have been detected, try to set up the
+      // contextual links for those entities that already have the necessary
+      // meta- data in the client-side cache.
+      contextualLinksQueue = _.filter(
+        contextualLinksQueue,
+        contextualLink => !initializeEntityContextualLink(contextualLink),
+      );
 
-    $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each((index, fieldElement) => {
-      // Delete field models.
-      Drupal.quickedit.collections.fields.chain()
-        .filter(fieldModel => fieldModel.get('el') === fieldElement)
-        .invoke('destroy');
+      // Fetch metadata for any fields that are queued to retrieve it.
+      fetchMissingMetadata(fieldElementsWithFreshMetadata => {
+        // Metadata has been fetched, reprocess fields whose metadata was
+        // missing.
+        _.each(fieldElementsWithFreshMetadata, processField);
 
-      // Filter queues.
-      function hasOtherFieldElement(field) {
-        return field.el !== fieldElement;
+        // Metadata has been fetched, try to set up more contextual links now.
+        contextualLinksQueue = _.filter(
+          contextualLinksQueue,
+          contextualLink => !initializeEntityContextualLink(contextualLink),
+        );
+      });
+    },
+    detach(context, settings, trigger) {
+      if (trigger === 'unload') {
+        deleteContainedModelsAndQueues($(context));
       }
+    },
+  };
 
-      fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
-      fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
-    });
+  /**
+   *
+   * @namespace
+   */
+  Drupal.quickedit = {
+    /**
+     * A {@link Drupal.quickedit.AppView} instance.
+     */
+    app: null,
+
+    /**
+     * @type {object}
+     *
+     * @prop {Array.<Drupal.quickedit.EntityModel>} entities
+     * @prop {Array.<Drupal.quickedit.FieldModel>} fields
+     */
+    collections: {
+      // All in-place editable entities (Drupal.quickedit.EntityModel) on the
+      // page.
+      entities: null,
+      // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
+      fields: null,
+    },
+
+    /**
+     * In-place editors will register themselves in this object.
+     *
+     * @namespace
+     */
+    editors: {},
+
+    /**
+     * Per-field metadata that indicates whether in-place editing is allowed,
+     * which in-place editor should be used, etc.
+     *
+     * @namespace
+     */
+    metadata: {
+      /**
+       * Check if a field exists in storage.
+       *
+       * @param {string} fieldID
+       *   The field id to check.
+       *
+       * @return {bool}
+       *   Whether it was found or not.
+       */
+      has(fieldID) {
+        return storage.getItem(this._prefixFieldID(fieldID)) !== null;
+      },
+
+      /**
+       * Add metadata to a field id.
+       *
+       * @param {string} fieldID
+       *   The field ID to add data to.
+       * @param {object} metadata
+       *   Metadata to add.
+       */
+      add(fieldID, metadata) {
+        storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
+      },
+
+      /**
+       * Get a key from a field id.
+       *
+       * @param {string} fieldID
+       *   The field ID to check.
+       * @param {string} [key]
+       *   The key to check. If empty, will return all metadata.
+       *
+       * @return {object|*}
+       *   The value for the key, if defined. Otherwise will return all metadata
+       *   for the specified field id.
+       *
+       */
+      get(fieldID, key) {
+        const metadata = JSON.parse(
+          storage.getItem(this._prefixFieldID(fieldID)),
+        );
+        return typeof key === 'undefined' ? metadata : metadata[key];
+      },
+
+      /**
+       * Prefix the field id.
+       *
+       * @param {string} fieldID
+       *   The field id to prefix.
+       *
+       * @return {string}
+       *   A prefixed field id.
+       */
+      _prefixFieldID(fieldID) {
+        return `Drupal.quickedit.metadata.${fieldID}`;
+      },
+
+      /**
+       * Unprefix the field id.
+       *
+       * @param {string} fieldID
+       *   The field id to unprefix.
+       *
+       * @return {string}
+       *   An unprefixed field id.
+       */
+      _unprefixFieldID(fieldID) {
+        // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
+        return fieldID.substring(26);
+      },
+
+      /**
+       * Intersection calculation.
+       *
+       * @param {Array} fieldIDs
+       *   An array of field ids to compare to prefix field id.
+       *
+       * @return {Array}
+       *   The intersection found.
+       */
+      intersection(fieldIDs) {
+        const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
+        const intersection = _.intersection(
+          prefixedFieldIDs,
+          _.keys(sessionStorage),
+        );
+        return _.map(intersection, this._unprefixFieldID);
+      },
+    },
+  };
+
+  // Clear the Quick Edit metadata cache whenever the current user's set of
+  // permissions changes.
+  const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID(
+    'permissionsHash',
+  );
+  const permissionsHashValue = storage.getItem(permissionsHashKey);
+  const permissionsHash = drupalSettings.user.permissionsHash;
+  if (permissionsHashValue !== permissionsHash) {
+    if (typeof permissionsHash === 'string') {
+      _.chain(storage)
+        .keys()
+        .each(key => {
+          if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
+            storage.removeItem(key);
+          }
+        });
+    }
+    storage.setItem(permissionsHashKey, permissionsHash);
   }
-}(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage));
+
+  /**
+   * Detect contextual links on entities annotated by quickedit.
+   *
+   * Queue contextual links to be processed.
+   *
+   * @param {jQuery.Event} event
+   *   The `drupalContextualLinkAdded` event.
+   * @param {object} data
+   *   An object containing the data relevant to the event.
+   *
+   * @listens event:drupalContextualLinkAdded
+   */
+  $(document).on('drupalContextualLinkAdded', (event, data) => {
+    if (data.$region.is('[data-quickedit-entity-id]')) {
+      // If the contextual link is cached on the client side, an entity instance
+      // will not yet have been assigned. So assign one.
+      if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
+        data.$region.once('quickedit');
+        processEntity(data.$region.get(0));
+      }
+      const contextualLink = {
+        entityID: data.$region.attr('data-quickedit-entity-id'),
+        entityInstanceID: data.$region.attr(
+          'data-quickedit-entity-instance-id',
+        ),
+        el: data.$el[0],
+        region: data.$region[0],
+      };
+      // Set up contextual links for this, otherwise queue it to be set up
+      // later.
+      if (!initializeEntityContextualLink(contextualLink)) {
+        contextualLinksQueue.push(contextualLink);
+      }
+    }
+  });
+})(
+  jQuery,
+  _,
+  Backbone,
+  Drupal,
+  drupalSettings,
+  window.JSON,
+  window.sessionStorage,
+);