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) {
- 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.<Drupal.quickedit.EntityModel>} entities
- * @prop {Array.<Drupal.quickedit.FieldModel>} fields
- */
- collections: {
- // All in-place editable entities (Drupal.quickedit.EntityModel) on the
- // page.
- entities: null,
- // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
- fields: null,
- },
- /**
- * In-place editors will register themselves in this object.
- *
- * @namespace
- */
- editors: {},
- /**
- * Per-field metadata that indicates whether in-place editing is allowed,
- * which in-place editor should be used, etc.
- *
- * @namespace
- */
- metadata: {
- /**
- * Check if a field exists in storage.
- *
- * @param {string} fieldID
- * The field id to check.
- *
- * @return {bool}
- * Whether it was found or not.
- */
- has(fieldID) {
- return storage.getItem(this._prefixFieldID(fieldID)) !== null;
- },
- /**
- * Add metadata to a field id.
- *
- * @param {string} fieldID
- * The field ID to add data to.
- * @param {object} metadata
- * Metadata to add.
- */
- add(fieldID, metadata) {
- storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
- },
- /**
- * Get a key from a field id.
- *
- * @param {string} fieldID
- * The field ID to check.
- * @param {string} [key]
- * The key to check. If empty, will return all metadata.
- *
- * @return {object|*}
- * The value for the key, if defined. Otherwise will return all metadata
- * for the specified field id.
- *
- */
- get(fieldID, key) {
- const metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
- return (typeof key === 'undefined') ? metadata : metadata[key];
- },
- /**
- * Prefix the field id.
- *
- * @param {string} fieldID
- * The field id to prefix.
- *
- * @return {string}
- * A prefixed field id.
- */
- _prefixFieldID(fieldID) {
- return `Drupal.quickedit.metadata.${fieldID}`;
- },
- /**
- * Unprefix the field id.
- *
- * @param {string} fieldID
- * The field id to unprefix.
- *
- * @return {string}
- * An unprefixed field id.
- */
- _unprefixFieldID(fieldID) {
- // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
- return fieldID.substring(26);
- },
- /**
- * Intersection calculation.
- *
- * @param {Array} fieldIDs
- * An array of field ids to compare to prefix field id.
- *
- * @return {Array}
- * The intersection found.
- */
- intersection(fieldIDs) {
- const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
- const intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
- return _.map(intersection, this._unprefixFieldID);
- },
- },
- };
- // Clear the Quick Edit metadata cache whenever the current user's set of
- // permissions changes.
- const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
- const permissionsHashValue = storage.getItem(permissionsHashKey);
- const permissionsHash = drupalSettings.user.permissionsHash;
- if (permissionsHashValue !== permissionsHash) {
- if (typeof permissionsHash === 'string') {
- _.chain(storage).keys().each((key) => {
- if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
- storage.removeItem(key);
- }
- });
- }
- storage.setItem(permissionsHashKey, permissionsHash);
- }
- /**
- * Detect contextual links on entities annotated by quickedit.
- *
- * Queue contextual links to be processed.
- *
- * @param {jQuery.Event} event
- * The `drupalContextualLinkAdded` event.
- * @param {object} data
- * An object containing the data relevant to the event.
- *
- * @listens event:drupalContextualLinkAdded
- */
- $(document).on('drupalContextualLinkAdded', (event, data) => {
- if (data.$region.is('[data-quickedit-entity-id]')) {
- // If the contextual link is cached on the client side, an entity instance
- // will not yet have been assigned. So assign one.
- if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
- data.$region.once('quickedit');
- processEntity(data.$region.get(0));
- }
- const contextualLink = {
- entityID: data.$region.attr('data-quickedit-entity-id'),
- entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'),
- el: data.$el[0],
- region: data.$region[0],
- };
- // Set up contextual links for this, otherwise queue it to be set up
- // later.
- if (!initializeEntityContextualLink(contextualLink)) {
- contextualLinksQueue.push(contextualLink);
- }
- }
- });
- /**
- * Extracts the entity ID from a field ID.
- *
- * @param {string} fieldID
- * A field ID: a string of the format
- * `<entity type>/<id>/<field name>/<language>/<view mode>`.
- *
- * @return {string}
- * An entity ID: a string of the format `<entity type>/<id>`.
- */
- function extractEntityID(fieldID) {
- return fieldID.split('/').slice(0, 2).join('/');
- }
- /**
- * Initialize the Quick Edit app.
- *
- * @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 new Error(`Quick Edit could not associate the rendered entity field markup (with [data-quickedit-field-id="${fieldID}"]) with the corresponding rendered entity markup: no parent DOM node found with [data-quickedit-entity-id="${entityID}"]. This is typically caused by the theme's template for this entity type forgetting to print the attributes.`);
- }
- let entityElement = $(fieldElement).closest($entityElement);
- // In the case of a full entity view page, the entity title is rendered
- // outside of "the entity DOM node": it's rendered as the page title. So in
- // this case, we find the lowest common parent element (deepest in the tree)
- // and consider that the entity element.
- if (entityElement.length === 0) {
- const $lowestCommonParent = $entityElement.parents().has(fieldElement).first();
- entityElement = $lowestCommonParent.find($entityElement);
- }
- const entityInstanceID = entityElement
- .get(0)
- .getAttribute('data-quickedit-entity-instance-id');
- // Early-return if metadata for this field is missing.
- if (!metadata.has(fieldID)) {
- fieldsMetadataQueue.push({
- el: fieldElement,
- fieldID,
- entityID,
- entityInstanceID,
- });
- return;
- }
- // Early-return if the user is not allowed to in-place edit this field.
- if (metadata.get(fieldID, 'access') !== true) {
- return;
- }
- // If an EntityModel for this field already exists (and hence also a "Quick
- // edit" contextual link), then initialize it immediately.
- if (Drupal.quickedit.collections.entities.findWhere({ entityID, entityInstanceID })) {
- initializeField(fieldElement, fieldID, entityID, entityInstanceID);
- }
- // Otherwise: queue the field. It is now available to be set up when its
- // corresponding entity becomes in-place editable.
- else {
- fieldsAvailableQueue.push({ el: fieldElement, fieldID, entityID, entityInstanceID });
- }
- }
- /**
- * Initialize a field; create FieldModel.
- *
- * @param {HTMLElement} fieldElement
- * The field's DOM element.
- * @param {string} fieldID
- * The field's ID.
- * @param {string} entityID
- * The field's entity's ID.
- * @param {string} entityInstanceID
- * The field's entity's instance ID.
- */
- function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
- const entity = Drupal.quickedit.collections.entities.findWhere({
- entityID,
- entityInstanceID,
- });
- $(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: $('<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((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));
|