3 * Attaches behavior for the Quick Edit module.
5 * Everything happens asynchronously, to allow for:
6 * - dynamically rendered contextual links
7 * - asynchronously retrieved (and cached) per-field in-place editing metadata
8 * - asynchronous setup of in-place editable field and "Quick edit" link.
10 * To achieve this, there are several queues:
11 * - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
12 * - fieldsAvailableQueue: queue of fields whose metadata is known, and for
13 * which it has been confirmed that the user has permission to edit them.
14 * However, FieldModels will only be created for them once there's a
15 * contextual link for their entity: when it's possible to initiate editing.
16 * - contextualLinksQueue: queue of contextual links on entities for which it
17 * is not yet known whether the user has permission to edit at >=1 of them.
20 (function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
21 const options = $.extend(
22 drupalSettings.quickedit,
23 // Merge strings on top of drupalSettings so that they are not mutable.
26 quickEdit: Drupal.t('Quick edit'),
32 * Tracks fields without metadata. Contains objects with the following keys:
37 let fieldsMetadataQueue = [];
40 * Tracks fields ready for use. Contains objects with the following keys:
45 let fieldsAvailableQueue = [];
48 * Tracks contextual links on entities. Contains objects with the following
54 let contextualLinksQueue = [];
57 * Tracks how many instances exist for each unique entity. Contains key-value
62 const entityInstancesTracker = {};
65 * Initialize the Quick Edit app.
67 * @param {HTMLElement} bodyElement
68 * This document's body element.
70 function initQuickEdit(bodyElement) {
71 Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
72 Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
74 // Instantiate AppModel (application state) and AppView, which is the
75 // controller of the whole in-place editing experience.
76 Drupal.quickedit.app = new Drupal.quickedit.AppView({
78 model: new Drupal.quickedit.AppModel(),
79 entitiesCollection: Drupal.quickedit.collections.entities,
80 fieldsCollection: Drupal.quickedit.collections.fields,
85 * Assigns the entity an instance ID.
87 * @param {HTMLElement} entityElement
88 * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
91 function processEntity(entityElement) {
92 const entityID = entityElement.getAttribute('data-quickedit-entity-id');
93 if (!entityInstancesTracker.hasOwnProperty(entityID)) {
94 entityInstancesTracker[entityID] = 0;
96 entityInstancesTracker[entityID]++;
99 // Set the calculated entity instance ID for this element.
100 const entityInstanceID = entityInstancesTracker[entityID];
101 entityElement.setAttribute(
102 'data-quickedit-entity-instance-id',
108 * Initialize a field; create FieldModel.
110 * @param {HTMLElement} fieldElement
111 * The field's DOM element.
112 * @param {string} fieldID
114 * @param {string} entityID
115 * The field's entity's ID.
116 * @param {string} entityInstanceID
117 * The field's entity's instance ID.
119 function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
120 const entity = Drupal.quickedit.collections.entities.findWhere({
125 $(fieldElement).addClass('quickedit-field');
127 // The FieldModel stores the state of an in-place editable entity field.
128 const field = new Drupal.quickedit.FieldModel({
131 id: `${fieldID}[${entity.get('entityInstanceID')}]`,
133 metadata: Drupal.quickedit.metadata.get(fieldID),
134 acceptStateChange: _.bind(
135 Drupal.quickedit.app.acceptEditorStateChange,
136 Drupal.quickedit.app,
140 // Track all fields on the page.
141 Drupal.quickedit.collections.fields.add(field);
145 * Loads missing in-place editor's attachments (JavaScript and CSS files).
147 * Missing in-place editors are those whose fields are actively being used on
148 * the page but don't have.
150 * @param {function} callback
151 * Callback function to be called when the missing in-place editors (if any)
152 * have been inserted into the DOM. i.e. they may still be loading.
154 function loadMissingEditors(callback) {
155 const loadedEditors = _.keys(Drupal.quickedit.editors);
156 let missingEditors = [];
157 Drupal.quickedit.collections.fields.each(fieldModel => {
158 const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
159 if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
160 missingEditors.push(metadata.editor);
161 // Set a stub, to prevent subsequent calls to loadMissingEditors() from
162 // loading the same in-place editor again. Loading an in-place editor
163 // requires talking to a server, to download its JavaScript, then
164 // executing its JavaScript, and only then its Drupal.quickedit.editors
165 // entry will be set.
166 Drupal.quickedit.editors[metadata.editor] = false;
169 missingEditors = _.uniq(missingEditors);
170 if (missingEditors.length === 0) {
175 // @see https://www.drupal.org/node/2029999.
176 // Create a Drupal.Ajax instance to load the form.
177 const loadEditorsAjax = Drupal.ajax({
178 url: Drupal.url('quickedit/attachments'),
179 submit: { 'editors[]': missingEditors },
181 // Implement a scoped insert AJAX command: calls the callback after all AJAX
182 // command functions have been executed (hence the deferred calling).
183 const realInsert = Drupal.AjaxCommands.prototype.insert;
184 loadEditorsAjax.commands.insert = function(ajax, response, status) {
186 realInsert(ajax, response, status);
188 // Trigger the AJAX request, which will should return AJAX commands to
189 // insert any missing attachments.
190 loadEditorsAjax.execute();
194 * Attempts to set up a "Quick edit" link and corresponding EntityModel.
196 * @param {object} contextualLink
197 * An object with the following properties:
198 * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
200 * - String entityInstanceID: a Quick Edit entity instance identifier,
201 * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
202 * instance of this entity).
203 * - DOM el: element pointing to the contextual links placeholder for this
205 * - DOM region: element pointing to the contextual region of this entity.
208 * Returns true when a contextual the given contextual link metadata can be
209 * removed from the queue (either because the contextual link has been set
210 * up or because it is certain that in-place editing is not allowed for any
211 * of its fields). Returns false otherwise.
213 function initializeEntityContextualLink(contextualLink) {
214 const metadata = Drupal.quickedit.metadata;
215 // Check if the user has permission to edit at least one of them.
216 function hasFieldWithPermission(fieldIDs) {
217 for (let i = 0; i < fieldIDs.length; i++) {
218 const fieldID = fieldIDs[i];
219 if (metadata.get(fieldID, 'access') === true) {
226 // Checks if the metadata for all given field IDs exists.
227 function allMetadataExists(fieldIDs) {
228 return fieldIDs.length === metadata.intersection(fieldIDs).length;
231 // Find all fields for this entity instance and collect their field IDs.
232 const fields = _.where(fieldsAvailableQueue, {
233 entityID: contextualLink.entityID,
234 entityInstanceID: contextualLink.entityInstanceID,
236 const fieldIDs = _.pluck(fields, 'fieldID');
238 // No fields found yet.
239 if (fieldIDs.length === 0) {
242 // The entity for the given contextual link contains at least one field that
243 // the current user may edit in-place; instantiate EntityModel,
244 // EntityDecorationView and ContextualLinkView.
245 if (hasFieldWithPermission(fieldIDs)) {
246 const entityModel = new Drupal.quickedit.EntityModel({
247 el: contextualLink.region,
248 entityID: contextualLink.entityID,
249 entityInstanceID: contextualLink.entityInstanceID,
250 id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
251 label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
253 Drupal.quickedit.collections.entities.add(entityModel);
254 // Create an EntityDecorationView associated with the root DOM node of the
256 const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
257 el: contextualLink.region,
260 entityModel.set('entityDecorationView', entityDecorationView);
262 // Initialize all queued fields within this entity (creates FieldModels).
263 _.each(fields, field => {
267 contextualLink.entityID,
268 contextualLink.entityInstanceID,
271 fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
273 // Initialization should only be called once. Use Underscore's once method
274 // to get a one-time use version of the function.
275 const initContextualLink = _.once(() => {
276 const $links = $(contextualLink.el).find('.contextual-links');
277 const contextualLinkView = new Drupal.quickedit.ContextualLinkView(
281 '<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>',
284 appModel: Drupal.quickedit.app.model,
289 entityModel.set('contextualLinkView', contextualLinkView);
292 // Set up ContextualLinkView after loading any missing in-place editors.
293 loadMissingEditors(initContextualLink);
297 // There was not at least one field that the current user may edit in-place,
298 // even though the metadata for all fields within this entity is available.
299 if (allMetadataExists(fieldIDs)) {
307 * Extracts the entity ID from a field ID.
309 * @param {string} fieldID
310 * A field ID: a string of the format
311 * `<entity type>/<id>/<field name>/<language>/<view mode>`.
314 * An entity ID: a string of the format `<entity type>/<id>`.
316 function extractEntityID(fieldID) {
324 * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
326 * @param {HTMLElement} fieldElement
327 * A Drupal Field API field's DOM element with a data-quickedit-field-id
330 function processField(fieldElement) {
331 const metadata = Drupal.quickedit.metadata;
332 const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
333 const entityID = extractEntityID(fieldID);
334 // Figure out the instance ID by looking at the ancestor
335 // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
337 const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
338 const $entityElement = $(entityElementSelector);
340 // If there are no elements returned from `entityElementSelector`
341 // throw an error. Check the browser console for this message.
342 if (!$entityElement.length) {
344 `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.`,
347 let entityElement = $(fieldElement).closest($entityElement);
349 // In the case of a full entity view page, the entity title is rendered
350 // outside of "the entity DOM node": it's rendered as the page title. So in
351 // this case, we find the lowest common parent element (deepest in the tree)
352 // and consider that the entity element.
353 if (entityElement.length === 0) {
354 const $lowestCommonParent = $entityElement
358 entityElement = $lowestCommonParent.find($entityElement);
360 const entityInstanceID = entityElement
362 .getAttribute('data-quickedit-entity-instance-id');
364 // Early-return if metadata for this field is missing.
365 if (!metadata.has(fieldID)) {
366 fieldsMetadataQueue.push({
374 // Early-return if the user is not allowed to in-place edit this field.
375 if (metadata.get(fieldID, 'access') !== true) {
379 // If an EntityModel for this field already exists (and hence also a "Quick
380 // edit" contextual link), then initialize it immediately.
382 Drupal.quickedit.collections.entities.findWhere({
387 initializeField(fieldElement, fieldID, entityID, entityInstanceID);
389 // Otherwise: queue the field. It is now available to be set up when its
390 // corresponding entity becomes in-place editable.
392 fieldsAvailableQueue.push({
402 * Delete models and queue items that are contained within a given context.
404 * Deletes any contained EntityModels (plus their associated FieldModels and
405 * ContextualLinkView) and FieldModels, as well as the corresponding queues.
407 * After EntityModels, FieldModels must also be deleted, because it is
408 * possible in Drupal for a field DOM element to exist outside of the entity
409 * DOM element, e.g. when viewing the full node, the title of the node is not
410 * rendered within the node (the entity) but as the page title.
412 * Note: this will not delete an entity that is actively being in-place
415 * @param {jQuery} $context
416 * The context within which to delete.
418 function deleteContainedModelsAndQueues($context) {
420 .find('[data-quickedit-entity-id]')
421 .addBack('[data-quickedit-entity-id]')
422 .each((index, entityElement) => {
423 // Delete entity model.
424 const entityModel = Drupal.quickedit.collections.entities.findWhere({
428 const contextualLinkView = entityModel.get('contextualLinkView');
429 contextualLinkView.undelegateEvents();
430 contextualLinkView.remove();
431 // Remove the EntityDecorationView.
432 entityModel.get('entityDecorationView').remove();
433 // Destroy the EntityModel; this will also destroy its FieldModels.
434 entityModel.destroy();
438 function hasOtherRegion(contextualLink) {
439 return contextualLink.region !== entityElement;
442 contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
446 .find('[data-quickedit-field-id]')
447 .addBack('[data-quickedit-field-id]')
448 .each((index, fieldElement) => {
449 // Delete field models.
450 Drupal.quickedit.collections.fields
452 .filter(fieldModel => fieldModel.get('el') === fieldElement)
456 function hasOtherFieldElement(field) {
457 return field.el !== fieldElement;
460 fieldsMetadataQueue = _.filter(
462 hasOtherFieldElement,
464 fieldsAvailableQueue = _.filter(
465 fieldsAvailableQueue,
466 hasOtherFieldElement,
472 * Fetches metadata for fields whose metadata is missing.
474 * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
476 * @param {function} callback
477 * A callback function that receives field elements whose metadata will just
480 function fetchMissingMetadata(callback) {
481 if (fieldsMetadataQueue.length) {
482 const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
483 const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
484 let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
485 // Ensure we only request entityIDs for which we don't have metadata yet.
486 entityIDs = _.difference(
488 Drupal.quickedit.metadata.intersection(entityIDs),
490 fieldsMetadataQueue = [];
493 url: Drupal.url('quickedit/metadata'),
496 'fields[]': fieldIDs,
497 'entities[]': entityIDs,
501 // Store the metadata.
502 _.each(results, (fieldMetadata, fieldID) => {
503 Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
506 callback(fieldElementsWithoutMetadata);
514 * @type {Drupal~behavior}
516 Drupal.behaviors.quickedit = {
518 // Initialize the Quick Edit app once per page load.
520 .once('quickedit-init')
521 .each(initQuickEdit);
523 // Find all in-place editable fields, if any.
524 const $fields = $(context)
525 .find('[data-quickedit-field-id]')
527 if ($fields.length === 0) {
531 // Process each entity element: identical entities that appear multiple
532 // times will get a numeric identifier, starting at 0.
534 .find('[data-quickedit-entity-id]')
536 .each((index, entityElement) => {
537 processEntity(entityElement);
540 // Process each field element: queue to be used or to fetch metadata.
541 // When a field is being rerendered after editing, it will be processed
542 // immediately. New fields will be unable to be processed immediately,
543 // but will instead be queued to have their metadata fetched, which occurs
544 // below in fetchMissingMetaData().
545 $fields.each((index, fieldElement) => {
546 processField(fieldElement);
549 // Entities and fields on the page have been detected, try to set up the
550 // contextual links for those entities that already have the necessary
551 // meta- data in the client-side cache.
552 contextualLinksQueue = _.filter(
553 contextualLinksQueue,
554 contextualLink => !initializeEntityContextualLink(contextualLink),
557 // Fetch metadata for any fields that are queued to retrieve it.
558 fetchMissingMetadata(fieldElementsWithFreshMetadata => {
559 // Metadata has been fetched, reprocess fields whose metadata was
561 _.each(fieldElementsWithFreshMetadata, processField);
563 // Metadata has been fetched, try to set up more contextual links now.
564 contextualLinksQueue = _.filter(
565 contextualLinksQueue,
566 contextualLink => !initializeEntityContextualLink(contextualLink),
570 detach(context, settings, trigger) {
571 if (trigger === 'unload') {
572 deleteContainedModelsAndQueues($(context));
583 * A {@link Drupal.quickedit.AppView} instance.
590 * @prop {Array.<Drupal.quickedit.EntityModel>} entities
591 * @prop {Array.<Drupal.quickedit.FieldModel>} fields
594 // All in-place editable entities (Drupal.quickedit.EntityModel) on the
597 // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
602 * In-place editors will register themselves in this object.
609 * Per-field metadata that indicates whether in-place editing is allowed,
610 * which in-place editor should be used, etc.
616 * Check if a field exists in storage.
618 * @param {string} fieldID
619 * The field id to check.
622 * Whether it was found or not.
625 return storage.getItem(this._prefixFieldID(fieldID)) !== null;
629 * Add metadata to a field id.
631 * @param {string} fieldID
632 * The field ID to add data to.
633 * @param {object} metadata
636 add(fieldID, metadata) {
637 storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
641 * Get a key from a field id.
643 * @param {string} fieldID
644 * The field ID to check.
645 * @param {string} [key]
646 * The key to check. If empty, will return all metadata.
649 * The value for the key, if defined. Otherwise will return all metadata
650 * for the specified field id.
654 const metadata = JSON.parse(
655 storage.getItem(this._prefixFieldID(fieldID)),
657 return typeof key === 'undefined' ? metadata : metadata[key];
661 * Prefix the field id.
663 * @param {string} fieldID
664 * The field id to prefix.
667 * A prefixed field id.
669 _prefixFieldID(fieldID) {
670 return `Drupal.quickedit.metadata.${fieldID}`;
674 * Unprefix the field id.
676 * @param {string} fieldID
677 * The field id to unprefix.
680 * An unprefixed field id.
682 _unprefixFieldID(fieldID) {
683 // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
684 return fieldID.substring(26);
688 * Intersection calculation.
690 * @param {Array} fieldIDs
691 * An array of field ids to compare to prefix field id.
694 * The intersection found.
696 intersection(fieldIDs) {
697 const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
698 const intersection = _.intersection(
700 _.keys(sessionStorage),
702 return _.map(intersection, this._unprefixFieldID);
707 // Clear the Quick Edit metadata cache whenever the current user's set of
708 // permissions changes.
709 const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID(
712 const permissionsHashValue = storage.getItem(permissionsHashKey);
713 const permissionsHash = drupalSettings.user.permissionsHash;
714 if (permissionsHashValue !== permissionsHash) {
715 if (typeof permissionsHash === 'string') {
719 if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
720 storage.removeItem(key);
724 storage.setItem(permissionsHashKey, permissionsHash);
728 * Detect contextual links on entities annotated by quickedit.
730 * Queue contextual links to be processed.
732 * @param {jQuery.Event} event
733 * The `drupalContextualLinkAdded` event.
734 * @param {object} data
735 * An object containing the data relevant to the event.
737 * @listens event:drupalContextualLinkAdded
739 $(document).on('drupalContextualLinkAdded', (event, data) => {
740 if (data.$region.is('[data-quickedit-entity-id]')) {
741 // If the contextual link is cached on the client side, an entity instance
742 // will not yet have been assigned. So assign one.
743 if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
744 data.$region.once('quickedit');
745 processEntity(data.$region.get(0));
747 const contextualLink = {
748 entityID: data.$region.attr('data-quickedit-entity-id'),
749 entityInstanceID: data.$region.attr(
750 'data-quickedit-entity-instance-id',
753 region: data.$region[0],
755 // Set up contextual links for this, otherwise queue it to be set up
757 if (!initializeEntityContextualLink(contextualLink)) {
758 contextualLinksQueue.push(contextualLink);
769 window.sessionStorage,