Chris@0: /** Chris@0: * @file Chris@0: * Attaches behavior for the Quick Edit module. Chris@0: * Chris@0: * Everything happens asynchronously, to allow for: Chris@0: * - dynamically rendered contextual links Chris@0: * - asynchronously retrieved (and cached) per-field in-place editing metadata Chris@0: * - asynchronous setup of in-place editable field and "Quick edit" link. Chris@0: * Chris@0: * To achieve this, there are several queues: Chris@0: * - fieldsMetadataQueue: fields whose metadata still needs to be fetched. Chris@0: * - fieldsAvailableQueue: queue of fields whose metadata is known, and for Chris@0: * which it has been confirmed that the user has permission to edit them. Chris@0: * However, FieldModels will only be created for them once there's a Chris@0: * contextual link for their entity: when it's possible to initiate editing. Chris@0: * - contextualLinksQueue: queue of contextual links on entities for which it Chris@0: * is not yet known whether the user has permission to edit at >=1 of them. Chris@0: */ Chris@0: Chris@17: (function($, _, Backbone, Drupal, drupalSettings, JSON, storage) { Chris@17: const options = $.extend( Chris@17: drupalSettings.quickedit, Chris@0: // Merge strings on top of drupalSettings so that they are not mutable. Chris@0: { Chris@0: strings: { Chris@0: quickEdit: Drupal.t('Quick edit'), Chris@0: }, Chris@0: }, Chris@0: ); Chris@0: Chris@0: /** Chris@0: * Tracks fields without metadata. Contains objects with the following keys: Chris@0: * - DOM el Chris@0: * - String fieldID Chris@0: * - String entityID Chris@0: */ Chris@0: let fieldsMetadataQueue = []; Chris@0: Chris@0: /** Chris@0: * Tracks fields ready for use. Contains objects with the following keys: Chris@0: * - DOM el Chris@0: * - String fieldID Chris@0: * - String entityID Chris@0: */ Chris@0: let fieldsAvailableQueue = []; Chris@0: Chris@0: /** Chris@0: * Tracks contextual links on entities. Contains objects with the following Chris@0: * keys: Chris@0: * - String entityID Chris@0: * - DOM el Chris@0: * - DOM region Chris@0: */ Chris@0: let contextualLinksQueue = []; Chris@0: Chris@0: /** Chris@0: * Tracks how many instances exist for each unique entity. Contains key-value Chris@0: * pairs: Chris@0: * - String entityID Chris@0: * - Number count Chris@0: */ Chris@0: const entityInstancesTracker = {}; Chris@0: Chris@0: /** Chris@0: * Initialize the Quick Edit app. Chris@0: * Chris@0: * @param {HTMLElement} bodyElement Chris@0: * This document's body element. Chris@0: */ Chris@0: function initQuickEdit(bodyElement) { Chris@0: Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection(); Chris@0: Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection(); Chris@0: Chris@0: // Instantiate AppModel (application state) and AppView, which is the Chris@0: // controller of the whole in-place editing experience. Chris@0: Drupal.quickedit.app = new Drupal.quickedit.AppView({ Chris@0: el: bodyElement, Chris@0: model: new Drupal.quickedit.AppModel(), Chris@0: entitiesCollection: Drupal.quickedit.collections.entities, Chris@0: fieldsCollection: Drupal.quickedit.collections.fields, Chris@0: }); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Assigns the entity an instance ID. Chris@0: * Chris@0: * @param {HTMLElement} entityElement Chris@0: * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id Chris@0: * attribute. Chris@0: */ Chris@0: function processEntity(entityElement) { Chris@0: const entityID = entityElement.getAttribute('data-quickedit-entity-id'); Chris@0: if (!entityInstancesTracker.hasOwnProperty(entityID)) { Chris@0: entityInstancesTracker[entityID] = 0; Chris@17: } else { Chris@0: entityInstancesTracker[entityID]++; Chris@0: } Chris@0: Chris@0: // Set the calculated entity instance ID for this element. Chris@0: const entityInstanceID = entityInstancesTracker[entityID]; Chris@17: entityElement.setAttribute( Chris@17: 'data-quickedit-entity-instance-id', Chris@17: entityInstanceID, Chris@17: ); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Initialize a field; create FieldModel. Chris@0: * Chris@0: * @param {HTMLElement} fieldElement Chris@0: * The field's DOM element. Chris@0: * @param {string} fieldID Chris@0: * The field's ID. Chris@0: * @param {string} entityID Chris@0: * The field's entity's ID. Chris@0: * @param {string} entityInstanceID Chris@0: * The field's entity's instance ID. Chris@0: */ Chris@0: function initializeField(fieldElement, fieldID, entityID, entityInstanceID) { Chris@0: const entity = Drupal.quickedit.collections.entities.findWhere({ Chris@0: entityID, Chris@0: entityInstanceID, Chris@0: }); Chris@0: Chris@0: $(fieldElement).addClass('quickedit-field'); Chris@0: Chris@0: // The FieldModel stores the state of an in-place editable entity field. Chris@0: const field = new Drupal.quickedit.FieldModel({ Chris@0: el: fieldElement, Chris@0: fieldID, Chris@0: id: `${fieldID}[${entity.get('entityInstanceID')}]`, Chris@0: entity, Chris@0: metadata: Drupal.quickedit.metadata.get(fieldID), Chris@17: acceptStateChange: _.bind( Chris@17: Drupal.quickedit.app.acceptEditorStateChange, Chris@17: Drupal.quickedit.app, Chris@17: ), Chris@0: }); Chris@0: Chris@0: // Track all fields on the page. Chris@0: Drupal.quickedit.collections.fields.add(field); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Loads missing in-place editor's attachments (JavaScript and CSS files). Chris@0: * Chris@0: * Missing in-place editors are those whose fields are actively being used on Chris@0: * the page but don't have. Chris@0: * Chris@0: * @param {function} callback Chris@0: * Callback function to be called when the missing in-place editors (if any) Chris@0: * have been inserted into the DOM. i.e. they may still be loading. Chris@0: */ Chris@0: function loadMissingEditors(callback) { Chris@0: const loadedEditors = _.keys(Drupal.quickedit.editors); Chris@0: let missingEditors = []; Chris@17: Drupal.quickedit.collections.fields.each(fieldModel => { Chris@0: const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID')); Chris@0: if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) { Chris@0: missingEditors.push(metadata.editor); Chris@0: // Set a stub, to prevent subsequent calls to loadMissingEditors() from Chris@0: // loading the same in-place editor again. Loading an in-place editor Chris@0: // requires talking to a server, to download its JavaScript, then Chris@0: // executing its JavaScript, and only then its Drupal.quickedit.editors Chris@0: // entry will be set. Chris@0: Drupal.quickedit.editors[metadata.editor] = false; Chris@0: } Chris@0: }); Chris@0: missingEditors = _.uniq(missingEditors); Chris@0: if (missingEditors.length === 0) { Chris@0: callback(); Chris@0: return; Chris@0: } Chris@0: Chris@0: // @see https://www.drupal.org/node/2029999. Chris@0: // Create a Drupal.Ajax instance to load the form. Chris@0: const loadEditorsAjax = Drupal.ajax({ Chris@0: url: Drupal.url('quickedit/attachments'), Chris@0: submit: { 'editors[]': missingEditors }, Chris@0: }); Chris@0: // Implement a scoped insert AJAX command: calls the callback after all AJAX Chris@0: // command functions have been executed (hence the deferred calling). Chris@0: const realInsert = Drupal.AjaxCommands.prototype.insert; Chris@17: loadEditorsAjax.commands.insert = function(ajax, response, status) { Chris@0: _.defer(callback); Chris@0: realInsert(ajax, response, status); Chris@0: }; Chris@0: // Trigger the AJAX request, which will should return AJAX commands to Chris@0: // insert any missing attachments. Chris@0: loadEditorsAjax.execute(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Attempts to set up a "Quick edit" link and corresponding EntityModel. Chris@0: * Chris@0: * @param {object} contextualLink Chris@0: * An object with the following properties: Chris@0: * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or Chris@0: * "block_content/5". Chris@0: * - String entityInstanceID: a Quick Edit entity instance identifier, Chris@0: * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st Chris@0: * instance of this entity). Chris@0: * - DOM el: element pointing to the contextual links placeholder for this Chris@0: * entity. Chris@0: * - DOM region: element pointing to the contextual region of this entity. Chris@0: * Chris@0: * @return {bool} Chris@0: * Returns true when a contextual the given contextual link metadata can be Chris@0: * removed from the queue (either because the contextual link has been set Chris@0: * up or because it is certain that in-place editing is not allowed for any Chris@0: * of its fields). Returns false otherwise. Chris@0: */ Chris@0: function initializeEntityContextualLink(contextualLink) { Chris@0: const metadata = Drupal.quickedit.metadata; Chris@0: // Check if the user has permission to edit at least one of them. Chris@0: function hasFieldWithPermission(fieldIDs) { Chris@0: for (let i = 0; i < fieldIDs.length; i++) { Chris@0: const fieldID = fieldIDs[i]; Chris@0: if (metadata.get(fieldID, 'access') === true) { Chris@0: return true; Chris@0: } Chris@0: } Chris@0: return false; Chris@0: } Chris@0: Chris@0: // Checks if the metadata for all given field IDs exists. Chris@0: function allMetadataExists(fieldIDs) { Chris@0: return fieldIDs.length === metadata.intersection(fieldIDs).length; Chris@0: } Chris@0: Chris@0: // Find all fields for this entity instance and collect their field IDs. Chris@0: const fields = _.where(fieldsAvailableQueue, { Chris@0: entityID: contextualLink.entityID, Chris@0: entityInstanceID: contextualLink.entityInstanceID, Chris@0: }); Chris@0: const fieldIDs = _.pluck(fields, 'fieldID'); Chris@0: Chris@0: // No fields found yet. Chris@0: if (fieldIDs.length === 0) { Chris@0: return false; Chris@0: } Chris@0: // The entity for the given contextual link contains at least one field that Chris@0: // the current user may edit in-place; instantiate EntityModel, Chris@0: // EntityDecorationView and ContextualLinkView. Chris@17: if (hasFieldWithPermission(fieldIDs)) { Chris@0: const entityModel = new Drupal.quickedit.EntityModel({ Chris@0: el: contextualLink.region, Chris@0: entityID: contextualLink.entityID, Chris@0: entityInstanceID: contextualLink.entityInstanceID, Chris@0: id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`, Chris@0: label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'), Chris@0: }); Chris@0: Drupal.quickedit.collections.entities.add(entityModel); Chris@0: // Create an EntityDecorationView associated with the root DOM node of the Chris@0: // entity. Chris@0: const entityDecorationView = new Drupal.quickedit.EntityDecorationView({ Chris@0: el: contextualLink.region, Chris@0: model: entityModel, Chris@0: }); Chris@0: entityModel.set('entityDecorationView', entityDecorationView); Chris@0: Chris@0: // Initialize all queued fields within this entity (creates FieldModels). Chris@17: _.each(fields, field => { Chris@17: initializeField( Chris@17: field.el, Chris@17: field.fieldID, Chris@17: contextualLink.entityID, Chris@17: contextualLink.entityInstanceID, Chris@17: ); Chris@0: }); Chris@0: fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields); Chris@0: Chris@0: // Initialization should only be called once. Use Underscore's once method Chris@0: // to get a one-time use version of the function. Chris@0: const initContextualLink = _.once(() => { Chris@0: const $links = $(contextualLink.el).find('.contextual-links'); Chris@17: const contextualLinkView = new Drupal.quickedit.ContextualLinkView( Chris@17: $.extend( Chris@17: { Chris@17: el: $( Chris@17: '
  • ', Chris@17: ).prependTo($links), Chris@17: model: entityModel, Chris@17: appModel: Drupal.quickedit.app.model, Chris@17: }, Chris@17: options, Chris@17: ), Chris@17: ); Chris@0: entityModel.set('contextualLinkView', contextualLinkView); Chris@0: }); Chris@0: Chris@0: // Set up ContextualLinkView after loading any missing in-place editors. Chris@0: loadMissingEditors(initContextualLink); Chris@0: Chris@0: return true; Chris@0: } Chris@0: // There was not at least one field that the current user may edit in-place, Chris@0: // even though the metadata for all fields within this entity is available. Chris@17: if (allMetadataExists(fieldIDs)) { Chris@0: return true; Chris@0: } Chris@0: Chris@0: return false; Chris@0: } Chris@0: Chris@0: /** Chris@17: * Extracts the entity ID from a field ID. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * A field ID: a string of the format Chris@17: * `////`. Chris@17: * Chris@17: * @return {string} Chris@17: * An entity ID: a string of the format `/`. Chris@17: */ Chris@17: function extractEntityID(fieldID) { Chris@17: return fieldID Chris@17: .split('/') Chris@17: .slice(0, 2) Chris@17: .join('/'); Chris@17: } Chris@17: Chris@17: /** Chris@17: * Fetch the field's metadata; queue or initialize it (if EntityModel exists). Chris@17: * Chris@17: * @param {HTMLElement} fieldElement Chris@17: * A Drupal Field API field's DOM element with a data-quickedit-field-id Chris@17: * attribute. Chris@17: */ Chris@17: function processField(fieldElement) { Chris@17: const metadata = Drupal.quickedit.metadata; Chris@17: const fieldID = fieldElement.getAttribute('data-quickedit-field-id'); Chris@17: const entityID = extractEntityID(fieldID); Chris@17: // Figure out the instance ID by looking at the ancestor Chris@17: // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id Chris@17: // attribute. Chris@17: const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`; Chris@17: const $entityElement = $(entityElementSelector); Chris@17: Chris@17: // If there are no elements returned from `entityElementSelector` Chris@17: // throw an error. Check the browser console for this message. Chris@17: if (!$entityElement.length) { Chris@17: throw new Error( Chris@17: `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.`, Chris@17: ); Chris@17: } Chris@17: let entityElement = $(fieldElement).closest($entityElement); Chris@17: Chris@17: // In the case of a full entity view page, the entity title is rendered Chris@17: // outside of "the entity DOM node": it's rendered as the page title. So in Chris@17: // this case, we find the lowest common parent element (deepest in the tree) Chris@17: // and consider that the entity element. Chris@17: if (entityElement.length === 0) { Chris@17: const $lowestCommonParent = $entityElement Chris@17: .parents() Chris@17: .has(fieldElement) Chris@17: .first(); Chris@17: entityElement = $lowestCommonParent.find($entityElement); Chris@17: } Chris@17: const entityInstanceID = entityElement Chris@17: .get(0) Chris@17: .getAttribute('data-quickedit-entity-instance-id'); Chris@17: Chris@17: // Early-return if metadata for this field is missing. Chris@17: if (!metadata.has(fieldID)) { Chris@17: fieldsMetadataQueue.push({ Chris@17: el: fieldElement, Chris@17: fieldID, Chris@17: entityID, Chris@17: entityInstanceID, Chris@17: }); Chris@17: return; Chris@17: } Chris@17: // Early-return if the user is not allowed to in-place edit this field. Chris@17: if (metadata.get(fieldID, 'access') !== true) { Chris@17: return; Chris@17: } Chris@17: Chris@17: // If an EntityModel for this field already exists (and hence also a "Quick Chris@17: // edit" contextual link), then initialize it immediately. Chris@17: if ( Chris@17: Drupal.quickedit.collections.entities.findWhere({ Chris@17: entityID, Chris@17: entityInstanceID, Chris@17: }) Chris@17: ) { Chris@17: initializeField(fieldElement, fieldID, entityID, entityInstanceID); Chris@17: } Chris@17: // Otherwise: queue the field. It is now available to be set up when its Chris@17: // corresponding entity becomes in-place editable. Chris@17: else { Chris@17: fieldsAvailableQueue.push({ Chris@17: el: fieldElement, Chris@17: fieldID, Chris@17: entityID, Chris@17: entityInstanceID, Chris@17: }); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@0: * Delete models and queue items that are contained within a given context. Chris@0: * Chris@0: * Deletes any contained EntityModels (plus their associated FieldModels and Chris@0: * ContextualLinkView) and FieldModels, as well as the corresponding queues. Chris@0: * Chris@0: * After EntityModels, FieldModels must also be deleted, because it is Chris@0: * possible in Drupal for a field DOM element to exist outside of the entity Chris@0: * DOM element, e.g. when viewing the full node, the title of the node is not Chris@0: * rendered within the node (the entity) but as the page title. Chris@0: * Chris@0: * Note: this will not delete an entity that is actively being in-place Chris@0: * edited. Chris@0: * Chris@0: * @param {jQuery} $context Chris@0: * The context within which to delete. Chris@0: */ Chris@0: function deleteContainedModelsAndQueues($context) { Chris@17: $context Chris@17: .find('[data-quickedit-entity-id]') Chris@17: .addBack('[data-quickedit-entity-id]') Chris@17: .each((index, entityElement) => { Chris@17: // Delete entity model. Chris@17: const entityModel = Drupal.quickedit.collections.entities.findWhere({ Chris@17: el: entityElement, Chris@17: }); Chris@17: if (entityModel) { Chris@17: const contextualLinkView = entityModel.get('contextualLinkView'); Chris@17: contextualLinkView.undelegateEvents(); Chris@17: contextualLinkView.remove(); Chris@17: // Remove the EntityDecorationView. Chris@17: entityModel.get('entityDecorationView').remove(); Chris@17: // Destroy the EntityModel; this will also destroy its FieldModels. Chris@17: entityModel.destroy(); Chris@17: } Chris@17: Chris@17: // Filter queue. Chris@17: function hasOtherRegion(contextualLink) { Chris@17: return contextualLink.region !== entityElement; Chris@17: } Chris@17: Chris@17: contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion); Chris@17: }); Chris@17: Chris@17: $context Chris@17: .find('[data-quickedit-field-id]') Chris@17: .addBack('[data-quickedit-field-id]') Chris@17: .each((index, fieldElement) => { Chris@17: // Delete field models. Chris@17: Drupal.quickedit.collections.fields Chris@17: .chain() Chris@17: .filter(fieldModel => fieldModel.get('el') === fieldElement) Chris@17: .invoke('destroy'); Chris@17: Chris@17: // Filter queues. Chris@17: function hasOtherFieldElement(field) { Chris@17: return field.el !== fieldElement; Chris@17: } Chris@17: Chris@17: fieldsMetadataQueue = _.filter( Chris@17: fieldsMetadataQueue, Chris@17: hasOtherFieldElement, Chris@17: ); Chris@17: fieldsAvailableQueue = _.filter( Chris@17: fieldsAvailableQueue, Chris@17: hasOtherFieldElement, Chris@17: ); Chris@17: }); Chris@17: } Chris@17: Chris@17: /** Chris@17: * Fetches metadata for fields whose metadata is missing. Chris@17: * Chris@17: * Fields whose metadata is missing are tracked at fieldsMetadataQueue. Chris@17: * Chris@17: * @param {function} callback Chris@17: * A callback function that receives field elements whose metadata will just Chris@17: * have been fetched. Chris@17: */ Chris@17: function fetchMissingMetadata(callback) { Chris@17: if (fieldsMetadataQueue.length) { Chris@17: const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID'); Chris@17: const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el'); Chris@17: let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true); Chris@17: // Ensure we only request entityIDs for which we don't have metadata yet. Chris@17: entityIDs = _.difference( Chris@17: entityIDs, Chris@17: Drupal.quickedit.metadata.intersection(entityIDs), Chris@17: ); Chris@17: fieldsMetadataQueue = []; Chris@17: Chris@17: $.ajax({ Chris@17: url: Drupal.url('quickedit/metadata'), Chris@17: type: 'POST', Chris@17: data: { Chris@17: 'fields[]': fieldIDs, Chris@17: 'entities[]': entityIDs, Chris@17: }, Chris@17: dataType: 'json', Chris@17: success(results) { Chris@17: // Store the metadata. Chris@17: _.each(results, (fieldMetadata, fieldID) => { Chris@17: Drupal.quickedit.metadata.add(fieldID, fieldMetadata); Chris@17: }); Chris@17: Chris@17: callback(fieldElementsWithoutMetadata); Chris@17: }, Chris@17: }); Chris@17: } Chris@17: } Chris@17: Chris@17: /** Chris@17: * Chris@17: * @type {Drupal~behavior} Chris@17: */ Chris@17: Drupal.behaviors.quickedit = { Chris@17: attach(context) { Chris@17: // Initialize the Quick Edit app once per page load. Chris@17: $('body') Chris@17: .once('quickedit-init') Chris@17: .each(initQuickEdit); Chris@17: Chris@17: // Find all in-place editable fields, if any. Chris@17: const $fields = $(context) Chris@17: .find('[data-quickedit-field-id]') Chris@17: .once('quickedit'); Chris@17: if ($fields.length === 0) { Chris@17: return; Chris@0: } Chris@0: Chris@17: // Process each entity element: identical entities that appear multiple Chris@17: // times will get a numeric identifier, starting at 0. Chris@17: $(context) Chris@17: .find('[data-quickedit-entity-id]') Chris@17: .once('quickedit') Chris@17: .each((index, entityElement) => { Chris@17: processEntity(entityElement); Chris@17: }); Chris@17: Chris@17: // Process each field element: queue to be used or to fetch metadata. Chris@17: // When a field is being rerendered after editing, it will be processed Chris@17: // immediately. New fields will be unable to be processed immediately, Chris@17: // but will instead be queued to have their metadata fetched, which occurs Chris@17: // below in fetchMissingMetaData(). Chris@17: $fields.each((index, fieldElement) => { Chris@17: processField(fieldElement); Chris@17: }); Chris@17: Chris@17: // Entities and fields on the page have been detected, try to set up the Chris@17: // contextual links for those entities that already have the necessary Chris@17: // meta- data in the client-side cache. Chris@17: contextualLinksQueue = _.filter( Chris@17: contextualLinksQueue, Chris@17: contextualLink => !initializeEntityContextualLink(contextualLink), Chris@17: ); Chris@17: Chris@17: // Fetch metadata for any fields that are queued to retrieve it. Chris@17: fetchMissingMetadata(fieldElementsWithFreshMetadata => { Chris@17: // Metadata has been fetched, reprocess fields whose metadata was Chris@17: // missing. Chris@17: _.each(fieldElementsWithFreshMetadata, processField); Chris@17: Chris@17: // Metadata has been fetched, try to set up more contextual links now. Chris@17: contextualLinksQueue = _.filter( Chris@17: contextualLinksQueue, Chris@17: contextualLink => !initializeEntityContextualLink(contextualLink), Chris@17: ); Chris@17: }); Chris@17: }, Chris@17: detach(context, settings, trigger) { Chris@17: if (trigger === 'unload') { Chris@17: deleteContainedModelsAndQueues($(context)); Chris@0: } Chris@17: }, Chris@17: }; Chris@0: Chris@17: /** Chris@17: * Chris@17: * @namespace Chris@17: */ Chris@17: Drupal.quickedit = { Chris@17: /** Chris@17: * A {@link Drupal.quickedit.AppView} instance. Chris@17: */ Chris@17: app: null, Chris@0: Chris@17: /** Chris@17: * @type {object} Chris@17: * Chris@17: * @prop {Array.} entities Chris@17: * @prop {Array.} fields Chris@17: */ Chris@17: collections: { Chris@17: // All in-place editable entities (Drupal.quickedit.EntityModel) on the Chris@17: // page. Chris@17: entities: null, Chris@17: // All in-place editable fields (Drupal.quickedit.FieldModel) on the page. Chris@17: fields: null, Chris@17: }, Chris@0: Chris@17: /** Chris@17: * In-place editors will register themselves in this object. Chris@17: * Chris@17: * @namespace Chris@17: */ Chris@17: editors: {}, Chris@17: Chris@17: /** Chris@17: * Per-field metadata that indicates whether in-place editing is allowed, Chris@17: * which in-place editor should be used, etc. Chris@17: * Chris@17: * @namespace Chris@17: */ Chris@17: metadata: { Chris@17: /** Chris@17: * Check if a field exists in storage. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * The field id to check. Chris@17: * Chris@17: * @return {bool} Chris@17: * Whether it was found or not. Chris@17: */ Chris@17: has(fieldID) { Chris@17: return storage.getItem(this._prefixFieldID(fieldID)) !== null; Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Add metadata to a field id. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * The field ID to add data to. Chris@17: * @param {object} metadata Chris@17: * Metadata to add. Chris@17: */ Chris@17: add(fieldID, metadata) { Chris@17: storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata)); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Get a key from a field id. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * The field ID to check. Chris@17: * @param {string} [key] Chris@17: * The key to check. If empty, will return all metadata. Chris@17: * Chris@17: * @return {object|*} Chris@17: * The value for the key, if defined. Otherwise will return all metadata Chris@17: * for the specified field id. Chris@17: * Chris@17: */ Chris@17: get(fieldID, key) { Chris@17: const metadata = JSON.parse( Chris@17: storage.getItem(this._prefixFieldID(fieldID)), Chris@17: ); Chris@17: return typeof key === 'undefined' ? metadata : metadata[key]; Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Prefix the field id. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * The field id to prefix. Chris@17: * Chris@17: * @return {string} Chris@17: * A prefixed field id. Chris@17: */ Chris@17: _prefixFieldID(fieldID) { Chris@17: return `Drupal.quickedit.metadata.${fieldID}`; Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Unprefix the field id. Chris@17: * Chris@17: * @param {string} fieldID Chris@17: * The field id to unprefix. Chris@17: * Chris@17: * @return {string} Chris@17: * An unprefixed field id. Chris@17: */ Chris@17: _unprefixFieldID(fieldID) { Chris@17: // Strip "Drupal.quickedit.metadata.", which is 26 characters long. Chris@17: return fieldID.substring(26); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Intersection calculation. Chris@17: * Chris@17: * @param {Array} fieldIDs Chris@17: * An array of field ids to compare to prefix field id. Chris@17: * Chris@17: * @return {Array} Chris@17: * The intersection found. Chris@17: */ Chris@17: intersection(fieldIDs) { Chris@17: const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID); Chris@17: const intersection = _.intersection( Chris@17: prefixedFieldIDs, Chris@17: _.keys(sessionStorage), Chris@17: ); Chris@17: return _.map(intersection, this._unprefixFieldID); Chris@17: }, Chris@17: }, Chris@17: }; Chris@17: Chris@17: // Clear the Quick Edit metadata cache whenever the current user's set of Chris@17: // permissions changes. Chris@17: const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID( Chris@17: 'permissionsHash', Chris@17: ); Chris@17: const permissionsHashValue = storage.getItem(permissionsHashKey); Chris@17: const permissionsHash = drupalSettings.user.permissionsHash; Chris@17: if (permissionsHashValue !== permissionsHash) { Chris@17: if (typeof permissionsHash === 'string') { Chris@17: _.chain(storage) Chris@17: .keys() Chris@17: .each(key => { Chris@17: if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') { Chris@17: storage.removeItem(key); Chris@17: } Chris@17: }); Chris@17: } Chris@17: storage.setItem(permissionsHashKey, permissionsHash); Chris@17: } Chris@17: Chris@17: /** Chris@17: * Detect contextual links on entities annotated by quickedit. Chris@17: * Chris@17: * Queue contextual links to be processed. Chris@17: * Chris@17: * @param {jQuery.Event} event Chris@17: * The `drupalContextualLinkAdded` event. Chris@17: * @param {object} data Chris@17: * An object containing the data relevant to the event. Chris@17: * Chris@17: * @listens event:drupalContextualLinkAdded Chris@17: */ Chris@17: $(document).on('drupalContextualLinkAdded', (event, data) => { Chris@17: if (data.$region.is('[data-quickedit-entity-id]')) { Chris@17: // If the contextual link is cached on the client side, an entity instance Chris@17: // will not yet have been assigned. So assign one. Chris@17: if (!data.$region.is('[data-quickedit-entity-instance-id]')) { Chris@17: data.$region.once('quickedit'); Chris@17: processEntity(data.$region.get(0)); Chris@0: } Chris@17: const contextualLink = { Chris@17: entityID: data.$region.attr('data-quickedit-entity-id'), Chris@17: entityInstanceID: data.$region.attr( Chris@17: 'data-quickedit-entity-instance-id', Chris@17: ), Chris@17: el: data.$el[0], Chris@17: region: data.$region[0], Chris@17: }; Chris@17: // Set up contextual links for this, otherwise queue it to be set up Chris@17: // later. Chris@17: if (!initializeEntityContextualLink(contextualLink)) { Chris@17: contextualLinksQueue.push(contextualLink); Chris@17: } Chris@17: } Chris@17: }); Chris@17: })( Chris@17: jQuery, Chris@17: _, Chris@17: Backbone, Chris@17: Drupal, Chris@17: drupalSettings, Chris@17: window.JSON, Chris@17: window.sessionStorage, Chris@17: );