| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 | /** * @file * Attaches behavior for the Quick Edit module. * * Everything happens asynchronously, to allow for: *   - dynamically rendered contextual links *   - asynchronously retrieved (and cached) per-field in-place editing metadata *   - asynchronous setup of in-place editable field and "Quick edit" link. * * To achieve this, there are several queues: *   - fieldsMetadataQueue: fields whose metadata still needs to be fetched. *   - fieldsAvailableQueue: queue of fields whose metadata is known, and for *     which it has been confirmed that the user has permission to edit them. *     However, FieldModels will only be created for them once there's a *     contextual link for their entity: when it's possible to initiate editing. *   - contextualLinksQueue: queue of contextual links on entities for which it *     is not yet known whether the user has permission to edit at >=1 of them. */(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {  'use strict';  var options = $.extend(drupalSettings.quickedit,    // Merge strings on top of drupalSettings so that they are not mutable.    {      strings: {        quickEdit: Drupal.t('Quick edit')      }    }  );  /**   * Tracks fields without metadata. Contains objects with the following keys:   *   - DOM el   *   - String fieldID   *   - String entityID   */  var fieldsMetadataQueue = [];  /**   * Tracks fields ready for use. Contains objects with the following keys:   *   - DOM el   *   - String fieldID   *   - String entityID   */  var fieldsAvailableQueue = [];  /**   * Tracks contextual links on entities. Contains objects with the following   * keys:   *   - String entityID   *   - DOM el   *   - DOM region   */  var contextualLinksQueue = [];  /**   * Tracks how many instances exist for each unique entity. Contains key-value   * pairs:   * - String entityID   * - Number count   */  var entityInstancesTracker = {};  /**   *   * @type {Drupal~behavior}   */  Drupal.behaviors.quickedit = {    attach: function (context) {      // Initialize the Quick Edit app once per page load.      $('body').once('quickedit-init').each(initQuickEdit);      // Find all in-place editable fields, if any.      var $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(function (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(function (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, function (contextualLink) {        return !initializeEntityContextualLink(contextualLink);      });      // Fetch metadata for any fields that are queued to retrieve it.      fetchMissingMetadata(function (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, function (contextualLink) {          return !initializeEntityContextualLink(contextualLink);        });      });    },    detach: function (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: function (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: function (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: function (fieldID, key) {        var 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: function (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: function (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: function (fieldIDs) {        var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);        var 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.  var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');  var permissionsHashValue = storage.getItem(permissionsHashKey);  var permissionsHash = drupalSettings.user.permissionsHash;  if (permissionsHashValue !== permissionsHash) {    if (typeof permissionsHash === 'string') {      _.chain(storage).keys().each(function (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', function (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));      }      var 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.   *   * @param {HTMLElement} bodyElement   *   This document's body element.   */  function initQuickEdit(bodyElement) {    Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();    Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();    // Instantiate AppModel (application state) and AppView, which is the    // controller of the whole in-place editing experience.    Drupal.quickedit.app = new Drupal.quickedit.AppView({      el: bodyElement,      model: new Drupal.quickedit.AppModel(),      entitiesCollection: Drupal.quickedit.collections.entities,      fieldsCollection: Drupal.quickedit.collections.fields    });  }  /**   * Assigns the entity an instance ID.   *   * @param {HTMLElement} entityElement   *   A Drupal Entity API entity's DOM element with a data-quickedit-entity-id   *   attribute.   */  function processEntity(entityElement) {    var entityID = entityElement.getAttribute('data-quickedit-entity-id');    if (!entityInstancesTracker.hasOwnProperty(entityID)) {      entityInstancesTracker[entityID] = 0;    }    else {      entityInstancesTracker[entityID]++;    }    // Set the calculated entity instance ID for this element.    var entityInstanceID = entityInstancesTracker[entityID];    entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);  }  /**   * 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) {    var metadata = Drupal.quickedit.metadata;    var fieldID = fieldElement.getAttribute('data-quickedit-field-id');    var 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.    var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]';    var entityElement = $(fieldElement).closest(entityElementSelector);    // 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) {      var $lowestCommonParent = $(entityElementSelector).parents().has(fieldElement).first();      entityElement = $lowestCommonParent.find(entityElementSelector);    }    var 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: fieldID,        entityID: entityID,        entityInstanceID: 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: entityID, entityInstanceID: 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: fieldID, entityID: entityID, entityInstanceID: 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) {    var entity = Drupal.quickedit.collections.entities.findWhere({      entityID: entityID,      entityInstanceID: entityInstanceID    });    $(fieldElement).addClass('quickedit-field');    // The FieldModel stores the state of an in-place editable entity field.    var field = new Drupal.quickedit.FieldModel({      el: fieldElement,      fieldID: fieldID,      id: fieldID + '[' + entity.get('entityInstanceID') + ']',      entity: entity,      metadata: Drupal.quickedit.metadata.get(fieldID),      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) {      var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');      var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');      var 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: function (results) {          // Store the metadata.          _.each(results, function (fieldMetadata, fieldID) {            Drupal.quickedit.metadata.add(fieldID, fieldMetadata);          });          callback(fieldElementsWithoutMetadata);        }      });    }  }  /**   * Loads missing in-place editor's attachments (JavaScript and CSS files).   *   * Missing in-place editors are those whose fields are actively being used on   * the page but don't have.   *   * @param {function} callback   *   Callback function to be called when the missing in-place editors (if any)   *   have been inserted into the DOM. i.e. they may still be loading.   */  function loadMissingEditors(callback) {    var loadedEditors = _.keys(Drupal.quickedit.editors);    var missingEditors = [];    Drupal.quickedit.collections.fields.each(function (fieldModel) {      var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));      if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {        missingEditors.push(metadata.editor);        // Set a stub, to prevent subsequent calls to loadMissingEditors() from        // loading the same in-place editor again. Loading an in-place editor        // requires talking to a server, to download its JavaScript, then        // executing its JavaScript, and only then its Drupal.quickedit.editors        // entry will be set.        Drupal.quickedit.editors[metadata.editor] = false;      }    });    missingEditors = _.uniq(missingEditors);    if (missingEditors.length === 0) {      callback();      return;    }    // @see https://www.drupal.org/node/2029999.    // Create a Drupal.Ajax instance to load the form.    var loadEditorsAjax = Drupal.ajax({      url: Drupal.url('quickedit/attachments'),      submit: {'editors[]': missingEditors}    });    // Implement a scoped insert AJAX command: calls the callback after all AJAX    // command functions have been executed (hence the deferred calling).    var realInsert = Drupal.AjaxCommands.prototype.insert;    loadEditorsAjax.commands.insert = function (ajax, response, status) {      _.defer(callback);      realInsert(ajax, response, status);    };    // Trigger the AJAX request, which will should return AJAX commands to    // insert any missing attachments.    loadEditorsAjax.execute();  }  /**   * Attempts to set up a "Quick edit" link and corresponding EntityModel.   *   * @param {object} contextualLink   *   An object with the following properties:   *     - String entityID: a Quick Edit entity identifier, e.g. "node/1" or   *       "block_content/5".   *     - String entityInstanceID: a Quick Edit entity instance identifier,   *       e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st   *       instance of this entity).   *     - DOM el: element pointing to the contextual links placeholder for this   *       entity.   *     - DOM region: element pointing to the contextual region of this entity.   *   * @return {bool}   *   Returns true when a contextual the given contextual link metadata can be   *   removed from the queue (either because the contextual link has been set   *   up or because it is certain that in-place editing is not allowed for any   *   of its fields). Returns false otherwise.   */  function initializeEntityContextualLink(contextualLink) {    var metadata = Drupal.quickedit.metadata;    // Check if the user has permission to edit at least one of them.    function hasFieldWithPermission(fieldIDs) {      for (var i = 0; i < fieldIDs.length; i++) {        var fieldID = fieldIDs[i];        if (metadata.get(fieldID, 'access') === true) {          return true;        }      }      return false;    }    // Checks if the metadata for all given field IDs exists.    function allMetadataExists(fieldIDs) {      return fieldIDs.length === metadata.intersection(fieldIDs).length;    }    // Find all fields for this entity instance and collect their field IDs.    var fields = _.where(fieldsAvailableQueue, {      entityID: contextualLink.entityID,      entityInstanceID: contextualLink.entityInstanceID    });    var fieldIDs = _.pluck(fields, 'fieldID');    // No fields found yet.    if (fieldIDs.length === 0) {      return false;    }    // 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)) {      var entityModel = new Drupal.quickedit.EntityModel({        el: contextualLink.region,        entityID: contextualLink.entityID,        entityInstanceID: contextualLink.entityInstanceID,        id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']',        label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label')      });      Drupal.quickedit.collections.entities.add(entityModel);      // Create an EntityDecorationView associated with the root DOM node of the      // entity.      var entityDecorationView = new Drupal.quickedit.EntityDecorationView({        el: contextualLink.region,        model: entityModel      });      entityModel.set('entityDecorationView', entityDecorationView);      // Initialize all queued fields within this entity (creates FieldModels).      _.each(fields, function (field) {        initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);      });      fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);      // Initialization should only be called once. Use Underscore's once method      // to get a one-time use version of the function.      var initContextualLink = _.once(function () {        var $links = $(contextualLink.el).find('.contextual-links');        var 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);      });      // Set up ContextualLinkView after loading any missing in-place editors.      loadMissingEditors(initContextualLink);      return true;    }    // 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)) {      return true;    }    return false;  }  /**   * Delete models and queue items that are contained within a given context.   *   * Deletes any contained EntityModels (plus their associated FieldModels and   * ContextualLinkView) and FieldModels, as well as the corresponding queues.   *   * After EntityModels, FieldModels must also be deleted, because it is   * possible in Drupal for a field DOM element to exist outside of the entity   * DOM element, e.g. when viewing the full node, the title of the node is not   * rendered within the node (the entity) but as the page title.   *   * Note: this will not delete an entity that is actively being in-place   * edited.   *   * @param {jQuery} $context   *   The context within which to delete.   */  function deleteContainedModelsAndQueues($context) {    $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) {      // Delete entity model.      var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement});      if (entityModel) {        var 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;      }      contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);    });    $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) {      // Delete field models.      Drupal.quickedit.collections.fields.chain()        .filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; })        .invoke('destroy');      // Filter queues.      function hasOtherFieldElement(field) {        return field.el !== fieldElement;      }      fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);      fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);    });  }})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
 |