/** * @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) { const 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 */ let fieldsMetadataQueue = []; /** * Tracks fields ready for use. Contains objects with the following keys: * - DOM el * - String fieldID * - String entityID */ let fieldsAvailableQueue = []; /** * Tracks contextual links on entities. Contains objects with the following * keys: * - String entityID * - DOM el * - DOM region */ let contextualLinksQueue = []; /** * Tracks how many instances exist for each unique entity. Contains key-value * pairs: * - String entityID * - Number count */ 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.} entities * @prop {Array.} 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 * `////`. * * @return {string} * An entity ID: a string of the format `/`. */ 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) { const 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. const 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) { 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 `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, }); $(fieldElement).addClass('quickedit-field'); // The FieldModel stores the state of an in-place editable entity field. const field = new Drupal.quickedit.FieldModel({ el: fieldElement, fieldID, id: `${fieldID}[${entity.get('entityInstanceID')}]`, 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) { 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). * * 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) { const loadedEditors = _.keys(Drupal.quickedit.editors); let missingEditors = []; 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); // 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. const 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). const 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) { const metadata = Drupal.quickedit.metadata; // Check if the user has permission to edit at least one of them. function hasFieldWithPermission(fieldIDs) { for (let i = 0; i < fieldIDs.length; i++) { const 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. const fields = _.where(fieldsAvailableQueue, { entityID: contextualLink.entityID, entityInstanceID: contextualLink.entityInstanceID, }); const 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)) { const 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. const 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, (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. const initContextualLink = _.once(() => { const $links = $(contextualLink.el).find('.contextual-links'); const contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({ el: $('
  • ').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((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; } 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); }); } }(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage));