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: );