Chris@0
|
1 /**
|
Chris@0
|
2 * @file
|
Chris@0
|
3 * Attaches behavior for the Quick Edit module.
|
Chris@0
|
4 *
|
Chris@0
|
5 * Everything happens asynchronously, to allow for:
|
Chris@0
|
6 * - dynamically rendered contextual links
|
Chris@0
|
7 * - asynchronously retrieved (and cached) per-field in-place editing metadata
|
Chris@0
|
8 * - asynchronous setup of in-place editable field and "Quick edit" link.
|
Chris@0
|
9 *
|
Chris@0
|
10 * To achieve this, there are several queues:
|
Chris@0
|
11 * - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
|
Chris@0
|
12 * - fieldsAvailableQueue: queue of fields whose metadata is known, and for
|
Chris@0
|
13 * which it has been confirmed that the user has permission to edit them.
|
Chris@0
|
14 * However, FieldModels will only be created for them once there's a
|
Chris@0
|
15 * contextual link for their entity: when it's possible to initiate editing.
|
Chris@0
|
16 * - contextualLinksQueue: queue of contextual links on entities for which it
|
Chris@0
|
17 * is not yet known whether the user has permission to edit at >=1 of them.
|
Chris@0
|
18 */
|
Chris@0
|
19
|
Chris@17
|
20 (function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
|
Chris@17
|
21 const options = $.extend(
|
Chris@17
|
22 drupalSettings.quickedit,
|
Chris@0
|
23 // Merge strings on top of drupalSettings so that they are not mutable.
|
Chris@0
|
24 {
|
Chris@0
|
25 strings: {
|
Chris@0
|
26 quickEdit: Drupal.t('Quick edit'),
|
Chris@0
|
27 },
|
Chris@0
|
28 },
|
Chris@0
|
29 );
|
Chris@0
|
30
|
Chris@0
|
31 /**
|
Chris@0
|
32 * Tracks fields without metadata. Contains objects with the following keys:
|
Chris@0
|
33 * - DOM el
|
Chris@0
|
34 * - String fieldID
|
Chris@0
|
35 * - String entityID
|
Chris@0
|
36 */
|
Chris@0
|
37 let fieldsMetadataQueue = [];
|
Chris@0
|
38
|
Chris@0
|
39 /**
|
Chris@0
|
40 * Tracks fields ready for use. Contains objects with the following keys:
|
Chris@0
|
41 * - DOM el
|
Chris@0
|
42 * - String fieldID
|
Chris@0
|
43 * - String entityID
|
Chris@0
|
44 */
|
Chris@0
|
45 let fieldsAvailableQueue = [];
|
Chris@0
|
46
|
Chris@0
|
47 /**
|
Chris@0
|
48 * Tracks contextual links on entities. Contains objects with the following
|
Chris@0
|
49 * keys:
|
Chris@0
|
50 * - String entityID
|
Chris@0
|
51 * - DOM el
|
Chris@0
|
52 * - DOM region
|
Chris@0
|
53 */
|
Chris@0
|
54 let contextualLinksQueue = [];
|
Chris@0
|
55
|
Chris@0
|
56 /**
|
Chris@0
|
57 * Tracks how many instances exist for each unique entity. Contains key-value
|
Chris@0
|
58 * pairs:
|
Chris@0
|
59 * - String entityID
|
Chris@0
|
60 * - Number count
|
Chris@0
|
61 */
|
Chris@0
|
62 const entityInstancesTracker = {};
|
Chris@0
|
63
|
Chris@0
|
64 /**
|
Chris@0
|
65 * Initialize the Quick Edit app.
|
Chris@0
|
66 *
|
Chris@0
|
67 * @param {HTMLElement} bodyElement
|
Chris@0
|
68 * This document's body element.
|
Chris@0
|
69 */
|
Chris@0
|
70 function initQuickEdit(bodyElement) {
|
Chris@0
|
71 Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
|
Chris@0
|
72 Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
|
Chris@0
|
73
|
Chris@0
|
74 // Instantiate AppModel (application state) and AppView, which is the
|
Chris@0
|
75 // controller of the whole in-place editing experience.
|
Chris@0
|
76 Drupal.quickedit.app = new Drupal.quickedit.AppView({
|
Chris@0
|
77 el: bodyElement,
|
Chris@0
|
78 model: new Drupal.quickedit.AppModel(),
|
Chris@0
|
79 entitiesCollection: Drupal.quickedit.collections.entities,
|
Chris@0
|
80 fieldsCollection: Drupal.quickedit.collections.fields,
|
Chris@0
|
81 });
|
Chris@0
|
82 }
|
Chris@0
|
83
|
Chris@0
|
84 /**
|
Chris@0
|
85 * Assigns the entity an instance ID.
|
Chris@0
|
86 *
|
Chris@0
|
87 * @param {HTMLElement} entityElement
|
Chris@0
|
88 * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
|
Chris@0
|
89 * attribute.
|
Chris@0
|
90 */
|
Chris@0
|
91 function processEntity(entityElement) {
|
Chris@0
|
92 const entityID = entityElement.getAttribute('data-quickedit-entity-id');
|
Chris@0
|
93 if (!entityInstancesTracker.hasOwnProperty(entityID)) {
|
Chris@0
|
94 entityInstancesTracker[entityID] = 0;
|
Chris@17
|
95 } else {
|
Chris@0
|
96 entityInstancesTracker[entityID]++;
|
Chris@0
|
97 }
|
Chris@0
|
98
|
Chris@0
|
99 // Set the calculated entity instance ID for this element.
|
Chris@0
|
100 const entityInstanceID = entityInstancesTracker[entityID];
|
Chris@17
|
101 entityElement.setAttribute(
|
Chris@17
|
102 'data-quickedit-entity-instance-id',
|
Chris@17
|
103 entityInstanceID,
|
Chris@17
|
104 );
|
Chris@0
|
105 }
|
Chris@0
|
106
|
Chris@0
|
107 /**
|
Chris@0
|
108 * Initialize a field; create FieldModel.
|
Chris@0
|
109 *
|
Chris@0
|
110 * @param {HTMLElement} fieldElement
|
Chris@0
|
111 * The field's DOM element.
|
Chris@0
|
112 * @param {string} fieldID
|
Chris@0
|
113 * The field's ID.
|
Chris@0
|
114 * @param {string} entityID
|
Chris@0
|
115 * The field's entity's ID.
|
Chris@0
|
116 * @param {string} entityInstanceID
|
Chris@0
|
117 * The field's entity's instance ID.
|
Chris@0
|
118 */
|
Chris@0
|
119 function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
|
Chris@0
|
120 const entity = Drupal.quickedit.collections.entities.findWhere({
|
Chris@0
|
121 entityID,
|
Chris@0
|
122 entityInstanceID,
|
Chris@0
|
123 });
|
Chris@0
|
124
|
Chris@0
|
125 $(fieldElement).addClass('quickedit-field');
|
Chris@0
|
126
|
Chris@0
|
127 // The FieldModel stores the state of an in-place editable entity field.
|
Chris@0
|
128 const field = new Drupal.quickedit.FieldModel({
|
Chris@0
|
129 el: fieldElement,
|
Chris@0
|
130 fieldID,
|
Chris@0
|
131 id: `${fieldID}[${entity.get('entityInstanceID')}]`,
|
Chris@0
|
132 entity,
|
Chris@0
|
133 metadata: Drupal.quickedit.metadata.get(fieldID),
|
Chris@17
|
134 acceptStateChange: _.bind(
|
Chris@17
|
135 Drupal.quickedit.app.acceptEditorStateChange,
|
Chris@17
|
136 Drupal.quickedit.app,
|
Chris@17
|
137 ),
|
Chris@0
|
138 });
|
Chris@0
|
139
|
Chris@0
|
140 // Track all fields on the page.
|
Chris@0
|
141 Drupal.quickedit.collections.fields.add(field);
|
Chris@0
|
142 }
|
Chris@0
|
143
|
Chris@0
|
144 /**
|
Chris@0
|
145 * Loads missing in-place editor's attachments (JavaScript and CSS files).
|
Chris@0
|
146 *
|
Chris@0
|
147 * Missing in-place editors are those whose fields are actively being used on
|
Chris@0
|
148 * the page but don't have.
|
Chris@0
|
149 *
|
Chris@0
|
150 * @param {function} callback
|
Chris@0
|
151 * Callback function to be called when the missing in-place editors (if any)
|
Chris@0
|
152 * have been inserted into the DOM. i.e. they may still be loading.
|
Chris@0
|
153 */
|
Chris@0
|
154 function loadMissingEditors(callback) {
|
Chris@0
|
155 const loadedEditors = _.keys(Drupal.quickedit.editors);
|
Chris@0
|
156 let missingEditors = [];
|
Chris@17
|
157 Drupal.quickedit.collections.fields.each(fieldModel => {
|
Chris@0
|
158 const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
|
Chris@0
|
159 if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
|
Chris@0
|
160 missingEditors.push(metadata.editor);
|
Chris@0
|
161 // Set a stub, to prevent subsequent calls to loadMissingEditors() from
|
Chris@0
|
162 // loading the same in-place editor again. Loading an in-place editor
|
Chris@0
|
163 // requires talking to a server, to download its JavaScript, then
|
Chris@0
|
164 // executing its JavaScript, and only then its Drupal.quickedit.editors
|
Chris@0
|
165 // entry will be set.
|
Chris@0
|
166 Drupal.quickedit.editors[metadata.editor] = false;
|
Chris@0
|
167 }
|
Chris@0
|
168 });
|
Chris@0
|
169 missingEditors = _.uniq(missingEditors);
|
Chris@0
|
170 if (missingEditors.length === 0) {
|
Chris@0
|
171 callback();
|
Chris@0
|
172 return;
|
Chris@0
|
173 }
|
Chris@0
|
174
|
Chris@0
|
175 // @see https://www.drupal.org/node/2029999.
|
Chris@0
|
176 // Create a Drupal.Ajax instance to load the form.
|
Chris@0
|
177 const loadEditorsAjax = Drupal.ajax({
|
Chris@0
|
178 url: Drupal.url('quickedit/attachments'),
|
Chris@0
|
179 submit: { 'editors[]': missingEditors },
|
Chris@0
|
180 });
|
Chris@0
|
181 // Implement a scoped insert AJAX command: calls the callback after all AJAX
|
Chris@0
|
182 // command functions have been executed (hence the deferred calling).
|
Chris@0
|
183 const realInsert = Drupal.AjaxCommands.prototype.insert;
|
Chris@17
|
184 loadEditorsAjax.commands.insert = function(ajax, response, status) {
|
Chris@0
|
185 _.defer(callback);
|
Chris@0
|
186 realInsert(ajax, response, status);
|
Chris@0
|
187 };
|
Chris@0
|
188 // Trigger the AJAX request, which will should return AJAX commands to
|
Chris@0
|
189 // insert any missing attachments.
|
Chris@0
|
190 loadEditorsAjax.execute();
|
Chris@0
|
191 }
|
Chris@0
|
192
|
Chris@0
|
193 /**
|
Chris@0
|
194 * Attempts to set up a "Quick edit" link and corresponding EntityModel.
|
Chris@0
|
195 *
|
Chris@0
|
196 * @param {object} contextualLink
|
Chris@0
|
197 * An object with the following properties:
|
Chris@0
|
198 * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
|
Chris@0
|
199 * "block_content/5".
|
Chris@0
|
200 * - String entityInstanceID: a Quick Edit entity instance identifier,
|
Chris@0
|
201 * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
|
Chris@0
|
202 * instance of this entity).
|
Chris@0
|
203 * - DOM el: element pointing to the contextual links placeholder for this
|
Chris@0
|
204 * entity.
|
Chris@0
|
205 * - DOM region: element pointing to the contextual region of this entity.
|
Chris@0
|
206 *
|
Chris@0
|
207 * @return {bool}
|
Chris@0
|
208 * Returns true when a contextual the given contextual link metadata can be
|
Chris@0
|
209 * removed from the queue (either because the contextual link has been set
|
Chris@0
|
210 * up or because it is certain that in-place editing is not allowed for any
|
Chris@0
|
211 * of its fields). Returns false otherwise.
|
Chris@0
|
212 */
|
Chris@0
|
213 function initializeEntityContextualLink(contextualLink) {
|
Chris@0
|
214 const metadata = Drupal.quickedit.metadata;
|
Chris@0
|
215 // Check if the user has permission to edit at least one of them.
|
Chris@0
|
216 function hasFieldWithPermission(fieldIDs) {
|
Chris@0
|
217 for (let i = 0; i < fieldIDs.length; i++) {
|
Chris@0
|
218 const fieldID = fieldIDs[i];
|
Chris@0
|
219 if (metadata.get(fieldID, 'access') === true) {
|
Chris@0
|
220 return true;
|
Chris@0
|
221 }
|
Chris@0
|
222 }
|
Chris@0
|
223 return false;
|
Chris@0
|
224 }
|
Chris@0
|
225
|
Chris@0
|
226 // Checks if the metadata for all given field IDs exists.
|
Chris@0
|
227 function allMetadataExists(fieldIDs) {
|
Chris@0
|
228 return fieldIDs.length === metadata.intersection(fieldIDs).length;
|
Chris@0
|
229 }
|
Chris@0
|
230
|
Chris@0
|
231 // Find all fields for this entity instance and collect their field IDs.
|
Chris@0
|
232 const fields = _.where(fieldsAvailableQueue, {
|
Chris@0
|
233 entityID: contextualLink.entityID,
|
Chris@0
|
234 entityInstanceID: contextualLink.entityInstanceID,
|
Chris@0
|
235 });
|
Chris@0
|
236 const fieldIDs = _.pluck(fields, 'fieldID');
|
Chris@0
|
237
|
Chris@0
|
238 // No fields found yet.
|
Chris@0
|
239 if (fieldIDs.length === 0) {
|
Chris@0
|
240 return false;
|
Chris@0
|
241 }
|
Chris@0
|
242 // The entity for the given contextual link contains at least one field that
|
Chris@0
|
243 // the current user may edit in-place; instantiate EntityModel,
|
Chris@0
|
244 // EntityDecorationView and ContextualLinkView.
|
Chris@17
|
245 if (hasFieldWithPermission(fieldIDs)) {
|
Chris@0
|
246 const entityModel = new Drupal.quickedit.EntityModel({
|
Chris@0
|
247 el: contextualLink.region,
|
Chris@0
|
248 entityID: contextualLink.entityID,
|
Chris@0
|
249 entityInstanceID: contextualLink.entityInstanceID,
|
Chris@0
|
250 id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
|
Chris@0
|
251 label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
|
Chris@0
|
252 });
|
Chris@0
|
253 Drupal.quickedit.collections.entities.add(entityModel);
|
Chris@0
|
254 // Create an EntityDecorationView associated with the root DOM node of the
|
Chris@0
|
255 // entity.
|
Chris@0
|
256 const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
|
Chris@0
|
257 el: contextualLink.region,
|
Chris@0
|
258 model: entityModel,
|
Chris@0
|
259 });
|
Chris@0
|
260 entityModel.set('entityDecorationView', entityDecorationView);
|
Chris@0
|
261
|
Chris@0
|
262 // Initialize all queued fields within this entity (creates FieldModels).
|
Chris@17
|
263 _.each(fields, field => {
|
Chris@17
|
264 initializeField(
|
Chris@17
|
265 field.el,
|
Chris@17
|
266 field.fieldID,
|
Chris@17
|
267 contextualLink.entityID,
|
Chris@17
|
268 contextualLink.entityInstanceID,
|
Chris@17
|
269 );
|
Chris@0
|
270 });
|
Chris@0
|
271 fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
|
Chris@0
|
272
|
Chris@0
|
273 // Initialization should only be called once. Use Underscore's once method
|
Chris@0
|
274 // to get a one-time use version of the function.
|
Chris@0
|
275 const initContextualLink = _.once(() => {
|
Chris@0
|
276 const $links = $(contextualLink.el).find('.contextual-links');
|
Chris@17
|
277 const contextualLinkView = new Drupal.quickedit.ContextualLinkView(
|
Chris@17
|
278 $.extend(
|
Chris@17
|
279 {
|
Chris@17
|
280 el: $(
|
Chris@17
|
281 '<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>',
|
Chris@17
|
282 ).prependTo($links),
|
Chris@17
|
283 model: entityModel,
|
Chris@17
|
284 appModel: Drupal.quickedit.app.model,
|
Chris@17
|
285 },
|
Chris@17
|
286 options,
|
Chris@17
|
287 ),
|
Chris@17
|
288 );
|
Chris@0
|
289 entityModel.set('contextualLinkView', contextualLinkView);
|
Chris@0
|
290 });
|
Chris@0
|
291
|
Chris@0
|
292 // Set up ContextualLinkView after loading any missing in-place editors.
|
Chris@0
|
293 loadMissingEditors(initContextualLink);
|
Chris@0
|
294
|
Chris@0
|
295 return true;
|
Chris@0
|
296 }
|
Chris@0
|
297 // There was not at least one field that the current user may edit in-place,
|
Chris@0
|
298 // even though the metadata for all fields within this entity is available.
|
Chris@17
|
299 if (allMetadataExists(fieldIDs)) {
|
Chris@0
|
300 return true;
|
Chris@0
|
301 }
|
Chris@0
|
302
|
Chris@0
|
303 return false;
|
Chris@0
|
304 }
|
Chris@0
|
305
|
Chris@0
|
306 /**
|
Chris@17
|
307 * Extracts the entity ID from a field ID.
|
Chris@17
|
308 *
|
Chris@17
|
309 * @param {string} fieldID
|
Chris@17
|
310 * A field ID: a string of the format
|
Chris@17
|
311 * `<entity type>/<id>/<field name>/<language>/<view mode>`.
|
Chris@17
|
312 *
|
Chris@17
|
313 * @return {string}
|
Chris@17
|
314 * An entity ID: a string of the format `<entity type>/<id>`.
|
Chris@17
|
315 */
|
Chris@17
|
316 function extractEntityID(fieldID) {
|
Chris@17
|
317 return fieldID
|
Chris@17
|
318 .split('/')
|
Chris@17
|
319 .slice(0, 2)
|
Chris@17
|
320 .join('/');
|
Chris@17
|
321 }
|
Chris@17
|
322
|
Chris@17
|
323 /**
|
Chris@17
|
324 * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
|
Chris@17
|
325 *
|
Chris@17
|
326 * @param {HTMLElement} fieldElement
|
Chris@17
|
327 * A Drupal Field API field's DOM element with a data-quickedit-field-id
|
Chris@17
|
328 * attribute.
|
Chris@17
|
329 */
|
Chris@17
|
330 function processField(fieldElement) {
|
Chris@17
|
331 const metadata = Drupal.quickedit.metadata;
|
Chris@17
|
332 const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
|
Chris@17
|
333 const entityID = extractEntityID(fieldID);
|
Chris@17
|
334 // Figure out the instance ID by looking at the ancestor
|
Chris@17
|
335 // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
|
Chris@17
|
336 // attribute.
|
Chris@17
|
337 const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
|
Chris@17
|
338 const $entityElement = $(entityElementSelector);
|
Chris@17
|
339
|
Chris@17
|
340 // If there are no elements returned from `entityElementSelector`
|
Chris@17
|
341 // throw an error. Check the browser console for this message.
|
Chris@17
|
342 if (!$entityElement.length) {
|
Chris@17
|
343 throw new Error(
|
Chris@17
|
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.`,
|
Chris@17
|
345 );
|
Chris@17
|
346 }
|
Chris@17
|
347 let entityElement = $(fieldElement).closest($entityElement);
|
Chris@17
|
348
|
Chris@17
|
349 // In the case of a full entity view page, the entity title is rendered
|
Chris@17
|
350 // outside of "the entity DOM node": it's rendered as the page title. So in
|
Chris@17
|
351 // this case, we find the lowest common parent element (deepest in the tree)
|
Chris@17
|
352 // and consider that the entity element.
|
Chris@17
|
353 if (entityElement.length === 0) {
|
Chris@17
|
354 const $lowestCommonParent = $entityElement
|
Chris@17
|
355 .parents()
|
Chris@17
|
356 .has(fieldElement)
|
Chris@17
|
357 .first();
|
Chris@17
|
358 entityElement = $lowestCommonParent.find($entityElement);
|
Chris@17
|
359 }
|
Chris@17
|
360 const entityInstanceID = entityElement
|
Chris@17
|
361 .get(0)
|
Chris@17
|
362 .getAttribute('data-quickedit-entity-instance-id');
|
Chris@17
|
363
|
Chris@17
|
364 // Early-return if metadata for this field is missing.
|
Chris@17
|
365 if (!metadata.has(fieldID)) {
|
Chris@17
|
366 fieldsMetadataQueue.push({
|
Chris@17
|
367 el: fieldElement,
|
Chris@17
|
368 fieldID,
|
Chris@17
|
369 entityID,
|
Chris@17
|
370 entityInstanceID,
|
Chris@17
|
371 });
|
Chris@17
|
372 return;
|
Chris@17
|
373 }
|
Chris@17
|
374 // Early-return if the user is not allowed to in-place edit this field.
|
Chris@17
|
375 if (metadata.get(fieldID, 'access') !== true) {
|
Chris@17
|
376 return;
|
Chris@17
|
377 }
|
Chris@17
|
378
|
Chris@17
|
379 // If an EntityModel for this field already exists (and hence also a "Quick
|
Chris@17
|
380 // edit" contextual link), then initialize it immediately.
|
Chris@17
|
381 if (
|
Chris@17
|
382 Drupal.quickedit.collections.entities.findWhere({
|
Chris@17
|
383 entityID,
|
Chris@17
|
384 entityInstanceID,
|
Chris@17
|
385 })
|
Chris@17
|
386 ) {
|
Chris@17
|
387 initializeField(fieldElement, fieldID, entityID, entityInstanceID);
|
Chris@17
|
388 }
|
Chris@17
|
389 // Otherwise: queue the field. It is now available to be set up when its
|
Chris@17
|
390 // corresponding entity becomes in-place editable.
|
Chris@17
|
391 else {
|
Chris@17
|
392 fieldsAvailableQueue.push({
|
Chris@17
|
393 el: fieldElement,
|
Chris@17
|
394 fieldID,
|
Chris@17
|
395 entityID,
|
Chris@17
|
396 entityInstanceID,
|
Chris@17
|
397 });
|
Chris@17
|
398 }
|
Chris@17
|
399 }
|
Chris@17
|
400
|
Chris@17
|
401 /**
|
Chris@0
|
402 * Delete models and queue items that are contained within a given context.
|
Chris@0
|
403 *
|
Chris@0
|
404 * Deletes any contained EntityModels (plus their associated FieldModels and
|
Chris@0
|
405 * ContextualLinkView) and FieldModels, as well as the corresponding queues.
|
Chris@0
|
406 *
|
Chris@0
|
407 * After EntityModels, FieldModels must also be deleted, because it is
|
Chris@0
|
408 * possible in Drupal for a field DOM element to exist outside of the entity
|
Chris@0
|
409 * DOM element, e.g. when viewing the full node, the title of the node is not
|
Chris@0
|
410 * rendered within the node (the entity) but as the page title.
|
Chris@0
|
411 *
|
Chris@0
|
412 * Note: this will not delete an entity that is actively being in-place
|
Chris@0
|
413 * edited.
|
Chris@0
|
414 *
|
Chris@0
|
415 * @param {jQuery} $context
|
Chris@0
|
416 * The context within which to delete.
|
Chris@0
|
417 */
|
Chris@0
|
418 function deleteContainedModelsAndQueues($context) {
|
Chris@17
|
419 $context
|
Chris@17
|
420 .find('[data-quickedit-entity-id]')
|
Chris@17
|
421 .addBack('[data-quickedit-entity-id]')
|
Chris@17
|
422 .each((index, entityElement) => {
|
Chris@17
|
423 // Delete entity model.
|
Chris@17
|
424 const entityModel = Drupal.quickedit.collections.entities.findWhere({
|
Chris@17
|
425 el: entityElement,
|
Chris@17
|
426 });
|
Chris@17
|
427 if (entityModel) {
|
Chris@17
|
428 const contextualLinkView = entityModel.get('contextualLinkView');
|
Chris@17
|
429 contextualLinkView.undelegateEvents();
|
Chris@17
|
430 contextualLinkView.remove();
|
Chris@17
|
431 // Remove the EntityDecorationView.
|
Chris@17
|
432 entityModel.get('entityDecorationView').remove();
|
Chris@17
|
433 // Destroy the EntityModel; this will also destroy its FieldModels.
|
Chris@17
|
434 entityModel.destroy();
|
Chris@17
|
435 }
|
Chris@17
|
436
|
Chris@17
|
437 // Filter queue.
|
Chris@17
|
438 function hasOtherRegion(contextualLink) {
|
Chris@17
|
439 return contextualLink.region !== entityElement;
|
Chris@17
|
440 }
|
Chris@17
|
441
|
Chris@17
|
442 contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
|
Chris@17
|
443 });
|
Chris@17
|
444
|
Chris@17
|
445 $context
|
Chris@17
|
446 .find('[data-quickedit-field-id]')
|
Chris@17
|
447 .addBack('[data-quickedit-field-id]')
|
Chris@17
|
448 .each((index, fieldElement) => {
|
Chris@17
|
449 // Delete field models.
|
Chris@17
|
450 Drupal.quickedit.collections.fields
|
Chris@17
|
451 .chain()
|
Chris@17
|
452 .filter(fieldModel => fieldModel.get('el') === fieldElement)
|
Chris@17
|
453 .invoke('destroy');
|
Chris@17
|
454
|
Chris@17
|
455 // Filter queues.
|
Chris@17
|
456 function hasOtherFieldElement(field) {
|
Chris@17
|
457 return field.el !== fieldElement;
|
Chris@17
|
458 }
|
Chris@17
|
459
|
Chris@17
|
460 fieldsMetadataQueue = _.filter(
|
Chris@17
|
461 fieldsMetadataQueue,
|
Chris@17
|
462 hasOtherFieldElement,
|
Chris@17
|
463 );
|
Chris@17
|
464 fieldsAvailableQueue = _.filter(
|
Chris@17
|
465 fieldsAvailableQueue,
|
Chris@17
|
466 hasOtherFieldElement,
|
Chris@17
|
467 );
|
Chris@17
|
468 });
|
Chris@17
|
469 }
|
Chris@17
|
470
|
Chris@17
|
471 /**
|
Chris@17
|
472 * Fetches metadata for fields whose metadata is missing.
|
Chris@17
|
473 *
|
Chris@17
|
474 * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
|
Chris@17
|
475 *
|
Chris@17
|
476 * @param {function} callback
|
Chris@17
|
477 * A callback function that receives field elements whose metadata will just
|
Chris@17
|
478 * have been fetched.
|
Chris@17
|
479 */
|
Chris@17
|
480 function fetchMissingMetadata(callback) {
|
Chris@17
|
481 if (fieldsMetadataQueue.length) {
|
Chris@17
|
482 const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
|
Chris@17
|
483 const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
|
Chris@17
|
484 let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
|
Chris@17
|
485 // Ensure we only request entityIDs for which we don't have metadata yet.
|
Chris@17
|
486 entityIDs = _.difference(
|
Chris@17
|
487 entityIDs,
|
Chris@17
|
488 Drupal.quickedit.metadata.intersection(entityIDs),
|
Chris@17
|
489 );
|
Chris@17
|
490 fieldsMetadataQueue = [];
|
Chris@17
|
491
|
Chris@17
|
492 $.ajax({
|
Chris@17
|
493 url: Drupal.url('quickedit/metadata'),
|
Chris@17
|
494 type: 'POST',
|
Chris@17
|
495 data: {
|
Chris@17
|
496 'fields[]': fieldIDs,
|
Chris@17
|
497 'entities[]': entityIDs,
|
Chris@17
|
498 },
|
Chris@17
|
499 dataType: 'json',
|
Chris@17
|
500 success(results) {
|
Chris@17
|
501 // Store the metadata.
|
Chris@17
|
502 _.each(results, (fieldMetadata, fieldID) => {
|
Chris@17
|
503 Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
|
Chris@17
|
504 });
|
Chris@17
|
505
|
Chris@17
|
506 callback(fieldElementsWithoutMetadata);
|
Chris@17
|
507 },
|
Chris@17
|
508 });
|
Chris@17
|
509 }
|
Chris@17
|
510 }
|
Chris@17
|
511
|
Chris@17
|
512 /**
|
Chris@17
|
513 *
|
Chris@17
|
514 * @type {Drupal~behavior}
|
Chris@17
|
515 */
|
Chris@17
|
516 Drupal.behaviors.quickedit = {
|
Chris@17
|
517 attach(context) {
|
Chris@17
|
518 // Initialize the Quick Edit app once per page load.
|
Chris@17
|
519 $('body')
|
Chris@17
|
520 .once('quickedit-init')
|
Chris@17
|
521 .each(initQuickEdit);
|
Chris@17
|
522
|
Chris@17
|
523 // Find all in-place editable fields, if any.
|
Chris@17
|
524 const $fields = $(context)
|
Chris@17
|
525 .find('[data-quickedit-field-id]')
|
Chris@17
|
526 .once('quickedit');
|
Chris@17
|
527 if ($fields.length === 0) {
|
Chris@17
|
528 return;
|
Chris@0
|
529 }
|
Chris@0
|
530
|
Chris@17
|
531 // Process each entity element: identical entities that appear multiple
|
Chris@17
|
532 // times will get a numeric identifier, starting at 0.
|
Chris@17
|
533 $(context)
|
Chris@17
|
534 .find('[data-quickedit-entity-id]')
|
Chris@17
|
535 .once('quickedit')
|
Chris@17
|
536 .each((index, entityElement) => {
|
Chris@17
|
537 processEntity(entityElement);
|
Chris@17
|
538 });
|
Chris@17
|
539
|
Chris@17
|
540 // Process each field element: queue to be used or to fetch metadata.
|
Chris@17
|
541 // When a field is being rerendered after editing, it will be processed
|
Chris@17
|
542 // immediately. New fields will be unable to be processed immediately,
|
Chris@17
|
543 // but will instead be queued to have their metadata fetched, which occurs
|
Chris@17
|
544 // below in fetchMissingMetaData().
|
Chris@17
|
545 $fields.each((index, fieldElement) => {
|
Chris@17
|
546 processField(fieldElement);
|
Chris@17
|
547 });
|
Chris@17
|
548
|
Chris@17
|
549 // Entities and fields on the page have been detected, try to set up the
|
Chris@17
|
550 // contextual links for those entities that already have the necessary
|
Chris@17
|
551 // meta- data in the client-side cache.
|
Chris@17
|
552 contextualLinksQueue = _.filter(
|
Chris@17
|
553 contextualLinksQueue,
|
Chris@17
|
554 contextualLink => !initializeEntityContextualLink(contextualLink),
|
Chris@17
|
555 );
|
Chris@17
|
556
|
Chris@17
|
557 // Fetch metadata for any fields that are queued to retrieve it.
|
Chris@17
|
558 fetchMissingMetadata(fieldElementsWithFreshMetadata => {
|
Chris@17
|
559 // Metadata has been fetched, reprocess fields whose metadata was
|
Chris@17
|
560 // missing.
|
Chris@17
|
561 _.each(fieldElementsWithFreshMetadata, processField);
|
Chris@17
|
562
|
Chris@17
|
563 // Metadata has been fetched, try to set up more contextual links now.
|
Chris@17
|
564 contextualLinksQueue = _.filter(
|
Chris@17
|
565 contextualLinksQueue,
|
Chris@17
|
566 contextualLink => !initializeEntityContextualLink(contextualLink),
|
Chris@17
|
567 );
|
Chris@17
|
568 });
|
Chris@17
|
569 },
|
Chris@17
|
570 detach(context, settings, trigger) {
|
Chris@17
|
571 if (trigger === 'unload') {
|
Chris@17
|
572 deleteContainedModelsAndQueues($(context));
|
Chris@0
|
573 }
|
Chris@17
|
574 },
|
Chris@17
|
575 };
|
Chris@0
|
576
|
Chris@17
|
577 /**
|
Chris@17
|
578 *
|
Chris@17
|
579 * @namespace
|
Chris@17
|
580 */
|
Chris@17
|
581 Drupal.quickedit = {
|
Chris@17
|
582 /**
|
Chris@17
|
583 * A {@link Drupal.quickedit.AppView} instance.
|
Chris@17
|
584 */
|
Chris@17
|
585 app: null,
|
Chris@0
|
586
|
Chris@17
|
587 /**
|
Chris@17
|
588 * @type {object}
|
Chris@17
|
589 *
|
Chris@17
|
590 * @prop {Array.<Drupal.quickedit.EntityModel>} entities
|
Chris@17
|
591 * @prop {Array.<Drupal.quickedit.FieldModel>} fields
|
Chris@17
|
592 */
|
Chris@17
|
593 collections: {
|
Chris@17
|
594 // All in-place editable entities (Drupal.quickedit.EntityModel) on the
|
Chris@17
|
595 // page.
|
Chris@17
|
596 entities: null,
|
Chris@17
|
597 // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
|
Chris@17
|
598 fields: null,
|
Chris@17
|
599 },
|
Chris@0
|
600
|
Chris@17
|
601 /**
|
Chris@17
|
602 * In-place editors will register themselves in this object.
|
Chris@17
|
603 *
|
Chris@17
|
604 * @namespace
|
Chris@17
|
605 */
|
Chris@17
|
606 editors: {},
|
Chris@17
|
607
|
Chris@17
|
608 /**
|
Chris@17
|
609 * Per-field metadata that indicates whether in-place editing is allowed,
|
Chris@17
|
610 * which in-place editor should be used, etc.
|
Chris@17
|
611 *
|
Chris@17
|
612 * @namespace
|
Chris@17
|
613 */
|
Chris@17
|
614 metadata: {
|
Chris@17
|
615 /**
|
Chris@17
|
616 * Check if a field exists in storage.
|
Chris@17
|
617 *
|
Chris@17
|
618 * @param {string} fieldID
|
Chris@17
|
619 * The field id to check.
|
Chris@17
|
620 *
|
Chris@17
|
621 * @return {bool}
|
Chris@17
|
622 * Whether it was found or not.
|
Chris@17
|
623 */
|
Chris@17
|
624 has(fieldID) {
|
Chris@17
|
625 return storage.getItem(this._prefixFieldID(fieldID)) !== null;
|
Chris@17
|
626 },
|
Chris@17
|
627
|
Chris@17
|
628 /**
|
Chris@17
|
629 * Add metadata to a field id.
|
Chris@17
|
630 *
|
Chris@17
|
631 * @param {string} fieldID
|
Chris@17
|
632 * The field ID to add data to.
|
Chris@17
|
633 * @param {object} metadata
|
Chris@17
|
634 * Metadata to add.
|
Chris@17
|
635 */
|
Chris@17
|
636 add(fieldID, metadata) {
|
Chris@17
|
637 storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
|
Chris@17
|
638 },
|
Chris@17
|
639
|
Chris@17
|
640 /**
|
Chris@17
|
641 * Get a key from a field id.
|
Chris@17
|
642 *
|
Chris@17
|
643 * @param {string} fieldID
|
Chris@17
|
644 * The field ID to check.
|
Chris@17
|
645 * @param {string} [key]
|
Chris@17
|
646 * The key to check. If empty, will return all metadata.
|
Chris@17
|
647 *
|
Chris@17
|
648 * @return {object|*}
|
Chris@17
|
649 * The value for the key, if defined. Otherwise will return all metadata
|
Chris@17
|
650 * for the specified field id.
|
Chris@17
|
651 *
|
Chris@17
|
652 */
|
Chris@17
|
653 get(fieldID, key) {
|
Chris@17
|
654 const metadata = JSON.parse(
|
Chris@17
|
655 storage.getItem(this._prefixFieldID(fieldID)),
|
Chris@17
|
656 );
|
Chris@17
|
657 return typeof key === 'undefined' ? metadata : metadata[key];
|
Chris@17
|
658 },
|
Chris@17
|
659
|
Chris@17
|
660 /**
|
Chris@17
|
661 * Prefix the field id.
|
Chris@17
|
662 *
|
Chris@17
|
663 * @param {string} fieldID
|
Chris@17
|
664 * The field id to prefix.
|
Chris@17
|
665 *
|
Chris@17
|
666 * @return {string}
|
Chris@17
|
667 * A prefixed field id.
|
Chris@17
|
668 */
|
Chris@17
|
669 _prefixFieldID(fieldID) {
|
Chris@17
|
670 return `Drupal.quickedit.metadata.${fieldID}`;
|
Chris@17
|
671 },
|
Chris@17
|
672
|
Chris@17
|
673 /**
|
Chris@17
|
674 * Unprefix the field id.
|
Chris@17
|
675 *
|
Chris@17
|
676 * @param {string} fieldID
|
Chris@17
|
677 * The field id to unprefix.
|
Chris@17
|
678 *
|
Chris@17
|
679 * @return {string}
|
Chris@17
|
680 * An unprefixed field id.
|
Chris@17
|
681 */
|
Chris@17
|
682 _unprefixFieldID(fieldID) {
|
Chris@17
|
683 // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
|
Chris@17
|
684 return fieldID.substring(26);
|
Chris@17
|
685 },
|
Chris@17
|
686
|
Chris@17
|
687 /**
|
Chris@17
|
688 * Intersection calculation.
|
Chris@17
|
689 *
|
Chris@17
|
690 * @param {Array} fieldIDs
|
Chris@17
|
691 * An array of field ids to compare to prefix field id.
|
Chris@17
|
692 *
|
Chris@17
|
693 * @return {Array}
|
Chris@17
|
694 * The intersection found.
|
Chris@17
|
695 */
|
Chris@17
|
696 intersection(fieldIDs) {
|
Chris@17
|
697 const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
|
Chris@17
|
698 const intersection = _.intersection(
|
Chris@17
|
699 prefixedFieldIDs,
|
Chris@17
|
700 _.keys(sessionStorage),
|
Chris@17
|
701 );
|
Chris@17
|
702 return _.map(intersection, this._unprefixFieldID);
|
Chris@17
|
703 },
|
Chris@17
|
704 },
|
Chris@17
|
705 };
|
Chris@17
|
706
|
Chris@17
|
707 // Clear the Quick Edit metadata cache whenever the current user's set of
|
Chris@17
|
708 // permissions changes.
|
Chris@17
|
709 const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID(
|
Chris@17
|
710 'permissionsHash',
|
Chris@17
|
711 );
|
Chris@17
|
712 const permissionsHashValue = storage.getItem(permissionsHashKey);
|
Chris@17
|
713 const permissionsHash = drupalSettings.user.permissionsHash;
|
Chris@17
|
714 if (permissionsHashValue !== permissionsHash) {
|
Chris@17
|
715 if (typeof permissionsHash === 'string') {
|
Chris@17
|
716 _.chain(storage)
|
Chris@17
|
717 .keys()
|
Chris@17
|
718 .each(key => {
|
Chris@17
|
719 if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
|
Chris@17
|
720 storage.removeItem(key);
|
Chris@17
|
721 }
|
Chris@17
|
722 });
|
Chris@17
|
723 }
|
Chris@17
|
724 storage.setItem(permissionsHashKey, permissionsHash);
|
Chris@17
|
725 }
|
Chris@17
|
726
|
Chris@17
|
727 /**
|
Chris@17
|
728 * Detect contextual links on entities annotated by quickedit.
|
Chris@17
|
729 *
|
Chris@17
|
730 * Queue contextual links to be processed.
|
Chris@17
|
731 *
|
Chris@17
|
732 * @param {jQuery.Event} event
|
Chris@17
|
733 * The `drupalContextualLinkAdded` event.
|
Chris@17
|
734 * @param {object} data
|
Chris@17
|
735 * An object containing the data relevant to the event.
|
Chris@17
|
736 *
|
Chris@17
|
737 * @listens event:drupalContextualLinkAdded
|
Chris@17
|
738 */
|
Chris@17
|
739 $(document).on('drupalContextualLinkAdded', (event, data) => {
|
Chris@17
|
740 if (data.$region.is('[data-quickedit-entity-id]')) {
|
Chris@17
|
741 // If the contextual link is cached on the client side, an entity instance
|
Chris@17
|
742 // will not yet have been assigned. So assign one.
|
Chris@17
|
743 if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
|
Chris@17
|
744 data.$region.once('quickedit');
|
Chris@17
|
745 processEntity(data.$region.get(0));
|
Chris@0
|
746 }
|
Chris@17
|
747 const contextualLink = {
|
Chris@17
|
748 entityID: data.$region.attr('data-quickedit-entity-id'),
|
Chris@17
|
749 entityInstanceID: data.$region.attr(
|
Chris@17
|
750 'data-quickedit-entity-instance-id',
|
Chris@17
|
751 ),
|
Chris@17
|
752 el: data.$el[0],
|
Chris@17
|
753 region: data.$region[0],
|
Chris@17
|
754 };
|
Chris@17
|
755 // Set up contextual links for this, otherwise queue it to be set up
|
Chris@17
|
756 // later.
|
Chris@17
|
757 if (!initializeEntityContextualLink(contextualLink)) {
|
Chris@17
|
758 contextualLinksQueue.push(contextualLink);
|
Chris@17
|
759 }
|
Chris@17
|
760 }
|
Chris@17
|
761 });
|
Chris@17
|
762 })(
|
Chris@17
|
763 jQuery,
|
Chris@17
|
764 _,
|
Chris@17
|
765 Backbone,
|
Chris@17
|
766 Drupal,
|
Chris@17
|
767 drupalSettings,
|
Chris@17
|
768 window.JSON,
|
Chris@17
|
769 window.sessionStorage,
|
Chris@17
|
770 );
|